feat(agents): mcplocal agents plugin + composePlugins helper (Stage 4)
When a Claude (or any other MCP client) connects to a project's mcplocal
endpoint, every Agent attached to that project now appears in the
session's tools/list as a virtual MCP server named `agent-<agentName>`
with one tool `chat`. Calling that tool POSTs to the Stage 3 chat
endpoint and returns the assistant's reply as MCP content. The tool's
description is the agent's own description, so connecting clients see
prose like "I review security design — ask me after each major change."
This is what makes one agent reachable from another's MCP session.
Plumbing:
* src/mcplocal/src/proxymodel/plugins/agents.ts (new) — the plugin.
onSessionCreate fetches /api/v1/projects/:p/agents via mcpd, then
registers a VirtualServer per agent. The chat tool's inputSchema
mirrors the LiteLLM-style override surface (temperature, top_p,
top_k, max_tokens, stop, seed, tools_allowlist, extra) plus
threadId for follow-ups. Namespace collision with an existing
upstream MCP server named `agent-<x>` is detected and skipped with
a `ctx.log.warn` line — better to surface the conflict than to
silently shadow real tool entries in the virtualTools map.
* src/mcplocal/src/proxymodel/plugins/compose.ts (new) — generic
N-plugin composition helper. Lifecycle hooks fan out in order;
transform hooks (onToolsList, onResourcesList, onPromptsList,
onToolCallAfter) pipeline; intercept hooks (onToolCallBefore,
onResourceRead, onPromptGet, onInitialize) short-circuit on the
first non-null. Generalizes what createDefaultPlugin does for
two fixed parents.
* src/mcplocal/src/http/project-mcp-endpoint.ts — every project
session now uses composePlugins([defaultPlugin, agentsPlugin]) so
agents show up no matter which proxymodel the project is on.
* Plugin context: added getFromMcpd(path) alongside postToMcpd. The
existing postToMcpd was hard-coded to POST; the agents plugin
needs GET to discover. Wired through plugin.ts → plugin-context.ts
→ router.ts.
Tests:
plugin-agents.test.ts (8) — registers per agent, falls back to a
generic description, skips on namespace collision, no-ops with
zero agents, logs and continues on mcpd error, chat handler
POSTs correct body and returns content array, isError surfacing
on mcpd error, onSessionDestroy unregisters everything.
plugin-compose.test.ts (6) — single-plugin pass-through, empty
rejection, lifecycle ordering, intercept short-circuit, list
pipeline, no-op composition stays minimal.
mcplocal suite: 715/715. mcpd suite still 759/759.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
164
src/mcplocal/tests/plugin-agents.test.ts
Normal file
164
src/mcplocal/tests/plugin-agents.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createAgentsPlugin } from '../src/proxymodel/plugins/agents.js';
|
||||
import type { PluginSessionContext, VirtualServer } from '../src/proxymodel/plugin.js';
|
||||
import type { ToolDefinition } from '../src/proxymodel/types.js';
|
||||
|
||||
function mockCtx(opts: {
|
||||
agents?: Array<{ id: string; name: string; description: string }> | Error;
|
||||
upstreamTools?: ToolDefinition[];
|
||||
postResponse?: unknown;
|
||||
} = {}): PluginSessionContext & {
|
||||
_registered: VirtualServer[];
|
||||
_unregistered: string[];
|
||||
_postCalls: Array<{ path: string; body: Record<string, unknown> }>;
|
||||
_warnings: string[];
|
||||
} {
|
||||
const registered: VirtualServer[] = [];
|
||||
const unregistered: string[] = [];
|
||||
const postCalls: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
const warnings: string[] = [];
|
||||
const state = new Map<string, unknown>();
|
||||
|
||||
const ctx = {
|
||||
sessionId: 'sess-1',
|
||||
projectName: 'mcpctl-dev',
|
||||
state,
|
||||
llm: {} as PluginSessionContext['llm'],
|
||||
cache: {} as PluginSessionContext['cache'],
|
||||
log: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => warnings.push(msg),
|
||||
error: () => undefined,
|
||||
},
|
||||
|
||||
registerTool: vi.fn(),
|
||||
unregisterTool: vi.fn(),
|
||||
registerServer: vi.fn((s: VirtualServer) => { registered.push(s); }),
|
||||
unregisterServer: vi.fn((name: string) => { unregistered.push(name); }),
|
||||
queueNotification: vi.fn(),
|
||||
|
||||
discoverTools: vi.fn(async () => opts.upstreamTools ?? []),
|
||||
routeToUpstream: vi.fn(),
|
||||
|
||||
fetchPromptIndex: vi.fn(async () => []),
|
||||
getSystemPrompt: vi.fn(async (_: string, fallback: string) => fallback),
|
||||
processContent: vi.fn(),
|
||||
|
||||
postToMcpd: vi.fn(async (path: string, body: Record<string, unknown>) => {
|
||||
postCalls.push({ path, body });
|
||||
return opts.postResponse ?? { assistant: 'hi back', threadId: 'thread-1', turnIndex: 1 };
|
||||
}),
|
||||
getFromMcpd: vi.fn(async (_path: string) => {
|
||||
if (opts.agents instanceof Error) throw opts.agents;
|
||||
return opts.agents ?? [];
|
||||
}),
|
||||
|
||||
emitAuditEvent: vi.fn(),
|
||||
|
||||
_registered: registered,
|
||||
_unregistered: unregistered,
|
||||
_postCalls: postCalls,
|
||||
_warnings: warnings,
|
||||
} as unknown as ReturnType<typeof mockCtx>;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe('agents plugin', () => {
|
||||
it('registers a virtual server per agent on session create', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [
|
||||
{ id: 'a1', name: 'reviewer', description: 'I review security design' },
|
||||
{ id: 'a2', name: 'deployer', description: 'I help you deploy' },
|
||||
],
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
expect(ctx._registered.map((s) => s.name)).toEqual(['agent-reviewer', 'agent-deployer']);
|
||||
// Tool description carries the agent's description.
|
||||
expect(ctx._registered[0]!.tools[0]!.definition.description).toBe('I review security design');
|
||||
});
|
||||
|
||||
it('falls back to a generic description when agent.description is empty', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [{ id: 'a1', name: 'silent', description: '' }],
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
expect(ctx._registered[0]!.tools[0]!.definition.description).toBe('Chat with agent silent');
|
||||
});
|
||||
|
||||
it('skips agents whose namespace collides with an upstream MCP server', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [{ id: 'a1', name: 'colliding', description: '' }],
|
||||
upstreamTools: [{ name: 'agent-colliding/something', description: '' }],
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
expect(ctx._registered).toHaveLength(0);
|
||||
expect(ctx._warnings.some((w) => /namespace collision/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the project has no agents', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({ agents: [] });
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
expect(ctx._registered).toEqual([]);
|
||||
});
|
||||
|
||||
it('logs and continues when fetching agents from mcpd fails', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({ agents: new Error('mcpd unreachable') });
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
expect(ctx._registered).toEqual([]);
|
||||
expect(ctx._warnings.some((w) => /mcpd unreachable/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it('chat tool POSTs to /api/v1/agents/:name/chat and returns the assistant text', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [{ id: 'a1', name: 'reviewer', description: 'I review' }],
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
|
||||
const handler = ctx._registered[0]!.tools[0]!.handler;
|
||||
const result = await handler({ message: 'security check?', temperature: 0.3 }, ctx);
|
||||
expect(ctx._postCalls).toHaveLength(1);
|
||||
expect(ctx._postCalls[0]!.path).toBe('/api/v1/agents/reviewer/chat');
|
||||
expect(ctx._postCalls[0]!.body).toMatchObject({
|
||||
message: 'security check?',
|
||||
temperature: 0.3,
|
||||
stream: false,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
content: [{ type: 'text', text: 'hi back' }],
|
||||
_meta: { threadId: 'thread-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('chat tool surfaces an mcpd error response as an isError content block', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [{ id: 'a1', name: 'reviewer', description: '' }],
|
||||
postResponse: { error: 'agent unhappy' },
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
const handler = ctx._registered[0]!.tools[0]!.handler;
|
||||
const result = await handler({ message: 'hi' }, ctx) as { isError: boolean; content: Array<{ text: string }> };
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0]!.text).toContain('agent unhappy');
|
||||
});
|
||||
|
||||
it('onSessionDestroy unregisters every server it registered', async () => {
|
||||
const plugin = createAgentsPlugin();
|
||||
const ctx = mockCtx({
|
||||
agents: [
|
||||
{ id: 'a1', name: 'one', description: '' },
|
||||
{ id: 'a2', name: 'two', description: '' },
|
||||
],
|
||||
});
|
||||
await plugin.onSessionCreate!(ctx);
|
||||
await plugin.onSessionDestroy!(ctx);
|
||||
expect(ctx._unregistered.sort()).toEqual(['agent-one', 'agent-two']);
|
||||
});
|
||||
});
|
||||
67
src/mcplocal/tests/plugin-compose.test.ts
Normal file
67
src/mcplocal/tests/plugin-compose.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { composePlugins } from '../src/proxymodel/plugins/compose.js';
|
||||
import type { ProxyModelPlugin, PluginSessionContext } from '../src/proxymodel/plugin.js';
|
||||
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
|
||||
|
||||
const fakeCtx = {} as PluginSessionContext;
|
||||
|
||||
function plugin(name: string, hooks: Partial<ProxyModelPlugin> = {}): ProxyModelPlugin {
|
||||
return { name, ...hooks };
|
||||
}
|
||||
|
||||
describe('composePlugins', () => {
|
||||
it('returns the single plugin when given one', () => {
|
||||
const p = plugin('only');
|
||||
expect(composePlugins([p])).toBe(p);
|
||||
});
|
||||
|
||||
it('throws when given an empty list', () => {
|
||||
expect(() => composePlugins([])).toThrow();
|
||||
});
|
||||
|
||||
it('chains onSessionCreate / onSessionDestroy in order', async () => {
|
||||
const calls: string[] = [];
|
||||
const a = plugin('a', {
|
||||
onSessionCreate: async () => { calls.push('a-create'); },
|
||||
onSessionDestroy: async () => { calls.push('a-destroy'); },
|
||||
});
|
||||
const b = plugin('b', {
|
||||
onSessionCreate: async () => { calls.push('b-create'); },
|
||||
onSessionDestroy: async () => { calls.push('b-destroy'); },
|
||||
});
|
||||
const composed = composePlugins([a, b]);
|
||||
await composed.onSessionCreate!(fakeCtx);
|
||||
await composed.onSessionDestroy!(fakeCtx);
|
||||
expect(calls).toEqual(['a-create', 'b-create', 'a-destroy', 'b-destroy']);
|
||||
});
|
||||
|
||||
it('first non-null onToolCallBefore short-circuits the chain', async () => {
|
||||
const aSpy = vi.fn(async () => null);
|
||||
const bSpy = vi.fn(async (): Promise<JsonRpcResponse> => ({ jsonrpc: '2.0', id: 1, result: 'B' }));
|
||||
const cSpy = vi.fn(async (): Promise<JsonRpcResponse> => ({ jsonrpc: '2.0', id: 1, result: 'C' }));
|
||||
const composed = composePlugins([
|
||||
plugin('a', { onToolCallBefore: aSpy }),
|
||||
plugin('b', { onToolCallBefore: bSpy }),
|
||||
plugin('c', { onToolCallBefore: cSpy }),
|
||||
]);
|
||||
const req: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'tools/call' };
|
||||
const res = await composed.onToolCallBefore!('foo', {}, req, fakeCtx);
|
||||
expect(res?.result).toBe('B');
|
||||
expect(cSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onToolsList pipelines through plugins (each transforms the previous output)', async () => {
|
||||
const composed = composePlugins([
|
||||
plugin('a', { onToolsList: async (tools) => [...tools, { name: 'a-added', description: '' }] }),
|
||||
plugin('b', { onToolsList: async (tools) => [...tools, { name: 'b-added', description: '' }] }),
|
||||
]);
|
||||
const out = await composed.onToolsList!([{ name: 'orig', description: '' }], fakeCtx);
|
||||
expect(out.map((t) => t.name)).toEqual(['orig', 'a-added', 'b-added']);
|
||||
});
|
||||
|
||||
it('does not declare hooks that no plugin provides (no-op composition stays minimal)', () => {
|
||||
const composed = composePlugins([plugin('a'), plugin('b')]);
|
||||
expect(composed.onSessionCreate).toBeUndefined();
|
||||
expect(composed.onToolsList).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user