feat(server): process-level plugin MCP tool source

Sibling of plugin-event-sink.ts: the plugins layer registers a source, and
src/mcp/tools.ts reads it as a defaulted argument. mcp-transport/ is not
touched at all, so it never learns that plugins exist and the tool-call
audit row for a plugin tool comes out of code that did not change.

Pull rather than push, unlike the event sink, because the answer depends
on the session's ctx: there is nothing a push could carry ahead of time,
and reading it per attach means activating or uninstalling a plugin takes
effect on the next session with nothing to invalidate. Same argument
channel-registry.ts makes for setPluginChannelSource.

An unset source means "no plugin tools" rather than an error, the right
answer for any app built without the plugins runtime. MCPSINK-003 pins
that case, which none of the sibling sinks covers today.

Not try/caught here: attach() already contains a throwing source and logs
which one failed, and catching again would swallow that.

The harness takes dynamicTools explicitly so suites exercise the path
without installing a process-level source.

MCPSINK-001 to MCPSINK-005.
This commit is contained in:
Renzo Beux
2026-08-28 13:20:08 -03:00
committed by Maurice
parent a6c96f4053
commit c56521b4a4
4 changed files with 131 additions and 4 deletions
+10 -2
View File
@@ -14,6 +14,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { registerTools } from '../../src/mcp/tools';
import type { McpAttachOptions } from '../../src/nest-mcp';
import { createMcpTestRegistry } from './mcp-test-controllers';
export interface McpHarness {
@@ -39,10 +40,17 @@ export interface McpHarnessOptions {
isStaticToken?: boolean;
/** Fire-once deprecation-notice closure (default: registerTools' () => null) */
getDeprecationNotice?: () => string | null;
/**
* Host-contributed tools for this session. Passed explicitly so a suite never
* has to install the process-level plugin source to exercise the path; left
* undefined, registerTools resolves it to that source, which is unset in
* every suite that does not register one.
*/
dynamicTools?: McpAttachOptions['dynamicTools'];
}
export async function createMcpHarness(options: McpHarnessOptions): Promise<McpHarness> {
const { userId, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice } = options;
const { userId, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice, dynamicTools } = options;
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
@@ -51,7 +59,7 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
// McpRegistryService to registerTools; the harness has no Nest app, so it
// builds the same registry by hand (see mcp-test-controllers.ts).
// registerTools' own ctx construction stays exercised.
registerTools(createMcpTestRegistry(), server, userId, scopes ?? null, isStaticToken, getDeprecationNotice);
registerTools(createMcpTestRegistry(), server, userId, scopes ?? null, isStaticToken, getDeprecationNotice, undefined, dynamicTools);
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -0,0 +1,77 @@
/**
* The process-level plugin MCP tool source — MCPSINK-001 to MCPSINK-005.
*
* The unset case is the one that matters and the one none of the sibling sinks
* covers: every app built without the plugins runtime, and every test that
* never registers a source, goes through it on each session.
*/
import { getPluginMcpToolSource, setPluginMcpToolSource } from '../../../src/plugin-mcp-tools';
import { registerTools } from '../../../src/mcp/tools';
import type { McpContext, McpDynamicTool } from '../../../src/nest-mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { afterEach, describe, expect, it, vi } from 'vitest';
const echo: McpDynamicTool = {
options: {
name: 'plugin_demo_echo',
description: 'Echoes.',
access: { group: 'plugins', mode: 'use' } as never,
},
handler: () => ({ content: [{ type: 'text', text: 'dynamic' }] }),
};
/** Records what attach() was handed, without needing a real registry. */
function spyRegistry() {
const attach = vi.fn();
return { attach, registry: { attach } as unknown as Parameters<typeof registerTools>[0] };
}
afterEach(() => {
setPluginMcpToolSource(null);
});
describe('setPluginMcpToolSource', () => {
it('MCPSINK-001: is unset by default', () => {
expect(getPluginMcpToolSource()).toBeNull();
});
it('MCPSINK-002: round-trips a source and clears back to null', () => {
const source = () => [echo];
setPluginMcpToolSource(source);
expect(getPluginMcpToolSource()).toBe(source);
setPluginMcpToolSource(null);
expect(getPluginMcpToolSource()).toBeNull();
});
});
describe('registerTools wiring', () => {
it('MCPSINK-003: passes no dynamic source when none is registered', () => {
const { attach, registry } = spyRegistry();
registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null);
expect(attach.mock.calls[0][2]).toEqual({ onInvoke: undefined, dynamicTools: undefined });
});
it('MCPSINK-004: passes the registered source through to attach', () => {
const source = (_ctx: McpContext) => [echo];
setPluginMcpToolSource(source);
const { attach, registry } = spyRegistry();
registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null);
expect(attach.mock.calls[0][2].dynamicTools).toBe(source);
});
it('MCPSINK-005: an explicit argument wins over the process-level source', () => {
setPluginMcpToolSource(() => [echo]);
const explicit = () => [];
const { attach, registry } = spyRegistry();
registerTools(registry, new McpServer({ name: 't', version: '1' }), 1, null, false, undefined, undefined, explicit);
expect(attach.mock.calls[0][2].dynamicTools).toBe(explicit);
});
});