Files
mcpctl/src/mcplocal/tests/plugin-compose.test.ts

68 lines
2.9 KiB
TypeScript
Raw Normal View History

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>
2026-04-25 16:51:44 +01:00
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();
});
});