diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 8a1687b63..05ddda883 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -946,6 +946,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes routesOf(id: string): PluginRouteInfo[] { return this.supervisor.routesOf(id); } + /** MCP tool names an active plugin reported implementing at load. */ + mcpToolsOf(id: string): string[] { + return this.supervisor.mcpToolsOf(id); + } + /** The grants an active plugin holds, for clamping what its tools may claim. */ + grantsOf(id: string): ReadonlySet { + return this.supervisor.grantsOf(id); + } invoke(id: string, method: string, params: Record, actingUserId?: number): Promise { return this.supervisor.invoke(id, method, params, { actingUserId }); } diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts index d44f3e53d..993df773e 100644 --- a/server/src/nest/plugins/runtime/plugin-host-entry.ts +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -110,7 +110,11 @@ async function boot(config: Record): Promise { // other-plugin events it subscribes to (so the host can route fan-out). const exportNames = Object.keys((def.exports ?? {}) as Record); const subscriptions = (def.subscriptions ?? []).map((s) => ({ plugin: s.plugin, event: s.event })); - send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions } }); + // Which of the manifest's mcpTools THIS BUILD implements. Declarative and + // synchronous: the host never asks the child what tools it has, it + // intersects this with the signed manifest. + const mcpTools = (def.hooks?.mcpToolProvider?.tools ?? []).filter((t) => typeof t === 'string'); + send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions, mcpTools } }); activated = true; // past load: a later async throw is a runtime CRASH, not a load failure // An immediate first heartbeat confirms liveness without waiting a full interval. send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index 3e2e0595e..673c88337 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -57,6 +57,7 @@ interface Supervised { hooks: string[]; // provider hooks the plugin implements (e.g. 'placeDetailProvider') events: string[]; // core events the plugin subscribes to (names or '*') exports: string[]; // functions the plugin exposes to dependents (ctx.plugins.call) + mcpTools: string[]; // MCP tool names the plugin reported implementing at load subscriptions: Array<{ plugin: string; event: string }>; // other-plugin events it listens to pending: Map; // host→child invokes awaiting a response invocations: Map; // reqId -> acting user of that invoke (undefined = no user, e.g. a job) @@ -144,6 +145,7 @@ export class PluginSupervisor { hooks: [], events: [], exports: [], + mcpTools: [], subscriptions: [], pending: new Map(), invocations: new Map(), @@ -257,6 +259,18 @@ export class PluginSupervisor { return sup && sup.status === 'active' ? sup.exports : []; } + /** MCP tool names an ACTIVE plugin reported at load. Intersect with the manifest. */ + mcpToolsOf(id: string): string[] { + const sup = this.running.get(id); + return sup && sup.status === 'active' ? sup.mcpTools : []; + } + + /** The grants an ACTIVE plugin holds. Read to clamp what its tools may claim. */ + grantsOf(id: string): ReadonlySet { + const sup = this.running.get(id); + return sup && sup.status === 'active' ? sup.granted : new Set(); + } + /** Ids of ACTIVE plugins that subscribed to `event` emitted by `sourceId`. */ subscribersOf(sourceId: string, event: string): string[] { const out: string[] = []; @@ -578,6 +592,7 @@ export class PluginSupervisor { const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: ScheduledJob[]; hooks?: string[]; events?: string[]; exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>; + mcpTools?: string[]; }; sup.routes = d.routes ?? []; sup.jobs = Array.isArray(d.jobs) @@ -586,6 +601,7 @@ export class PluginSupervisor { sup.hooks = d.hooks ?? []; sup.events = d.events ?? []; sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : []; + sup.mcpTools = Array.isArray(d.mcpTools) ? d.mcpTools.filter((t): t is string => typeof t === 'string') : []; sup.subscriptions = Array.isArray(d.subscriptions) ? d.subscriptions.filter((s): s is { plugin: string; event: string } => !!s && typeof s.plugin === 'string' && typeof s.event === 'string') : []; diff --git a/server/tests/integration/plugins/supervisor.test.ts b/server/tests/integration/plugins/supervisor.test.ts index 936129684..5cc9b0bb5 100644 --- a/server/tests/integration/plugins/supervisor.test.ts +++ b/server/tests/integration/plugins/supervisor.test.ts @@ -140,6 +140,53 @@ describe('PluginSupervisor — isolated runtime', () => { expect(got).toEqual({ event: 'place:created', tripId: 3 }); }); + it('reports its MCP tools at load and dispatches a tool call as the requesting user', async () => { + // plugin-host-entry.ts is excluded from coverage (it runs in a forked + // subprocess), so this is the only thing proving the loaded payload and the + // invoke path for mcpToolProvider. + const events: Array<{ topic: string; data: unknown }> = []; + sup = makeSupervisor(events); + writePlugin( + 'toolbox', + `module.exports = { + hooks: { + mcpToolProvider: { + tools: ['echo', 'whoami'], + async callTool(call, ctx) { return { name: call.name, args: call.args }; }, + }, + }, + };`, + ); + await sup.activate('toolbox', new Set(['mcp:tools']), {}); + + expect(sup.mcpToolsOf('toolbox')).toEqual(['echo', 'whoami']); + expect(sup.providersOf('mcpToolProvider')).toContain('toolbox'); + expect([...sup.grantsOf('toolbox')]).toContain('mcp:tools'); + + const res = await sup.invoke( + 'toolbox', + 'invoke.hook', + { hook: 'mcpToolProvider', fn: 'callTool', args: [{ name: 'echo', args: { v: 1 } }] }, + { actingUserId: 5 }, + ); + expect(res).toEqual({ name: 'echo', args: { v: 1 } }); + }); + + it('stops reporting MCP tools once the plugin is no longer active', async () => { + const events: Array<{ topic: string; data: unknown }> = []; + sup = makeSupervisor(events); + writePlugin( + 'toolbox2', + `module.exports = { hooks: { mcpToolProvider: { tools: ['echo'], async callTool() { return 1; } } } };`, + ); + await sup.activate('toolbox2', new Set(['mcp:tools']), {}); + expect(sup.mcpToolsOf('toolbox2')).toEqual(['echo']); + + await sup.disable('toolbox2'); + expect(sup.mcpToolsOf('toolbox2')).toEqual([]); + expect(sup.providersOf('mcpToolProvider')).not.toContain('toolbox2'); + }); + it('seals the raw IPC surface: a plugin cannot forge/sniff over process.send/on(message), and a forged init cannot reopen egress', async () => { const events: Array<{ topic: string; data: unknown }> = []; sup = makeSupervisor(events);