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
+8 -2
View File
@@ -1,5 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpAttachOptions, McpRegistry } from '../nest-mcp';
import { getPluginMcpToolSource } from '../plugin-mcp-tools';
/**
* Attaches the MCP surface to a session's server.
@@ -11,7 +12,12 @@ import type { McpAttachOptions, McpRegistry } from '../nest-mcp';
* (injected into the transport service); the no-Nest harness hands in
* createTestRegistry()'s build. A null registry (direct callers without either)
* skips the attach.
*
* `dynamicTools` defaults to the process-level plugin source rather than being
* passed down from the transport, so mcp-transport/ never learns that plugins
* exist and an app built without the plugins runtime resolves it to undefined.
* Tests pass it explicitly instead of reaching for the global.
*/
export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke']): void {
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke });
export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): void {
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools });
}
+36
View File
@@ -0,0 +1,36 @@
// The seam that lets a per-session MCP server carry tools contributed by installed
// plugins, without src/nest/mcp-transport/ gaining an edge to the plugin runtime —
// the same module cycle trip-warnings.mcp.ts documents. Sibling of
// plugin-event-sink.ts and plugin-user-lifecycle.ts; the plugins layer registers
// the source at bootstrap and clears it on shutdown.
import type { McpContext, McpDynamicTool } from './nest-mcp';
/**
* PULL rather than push, unlike setPluginEventSink: the answer depends on the
* session's ctx, so there is nothing a push could carry ahead of time, and
* reading it on every attach means activating or uninstalling a plugin takes
* effect on the next session with nothing to invalidate. That is the argument
* channel-registry.ts already makes for setPluginChannelSource.
*
* Unlike every sink beside it this one returns a value on the request path. An
* unset source is not an error: it means "no plugin tools", which is the right
* answer for any app built without the plugins runtime, and for every test that
* never registers one.
*
* Deliberately NOT wrapped in try/catch here. nest-mcp's attach() contains a
* throwing source per its own contract and logs which one failed; catching it
* again on the way out would swallow that diagnostic and report the same
* degraded surface less usefully.
*/
export type PluginMcpToolSource = (ctx: McpContext) => readonly McpDynamicTool[];
let source: PluginMcpToolSource | null = null;
export function setPluginMcpToolSource(fn: PluginMcpToolSource | null): void {
source = fn;
}
export function getPluginMcpToolSource(): PluginMcpToolSource | null {
return source;
}