mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): report implemented MCP tools at load
The child adds the tool names its mcpToolProvider hook implements to the loaded payload, beside routes/jobs/exports, and the supervisor caches them on Supervised like the rest. The hook itself already rides the existing Object.keys(def.hooks) line, so the "did the child report the hook too" half was free. Declarative and synchronous: the host never asks the child what tools it has. It intersects this list with the signed manifest, so a build that reports a tool the manifest never declared is ignored rather than trusted. grantsOf comes along because the advertise path has to clamp what a tool's annotations may claim against what the plugin was actually granted, and sup.granted is already in memory. A per-tool DB read would be the alternative. plugin-host-entry.ts is excluded from coverage (it runs in a forked subprocess), so the two supervisor integration cases are the only thing proving any of this: the loaded payload, the acting-user-bound invoke, and that both stop the moment the plugin is no longer active.
This commit is contained in:
@@ -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<string> {
|
||||
return this.supervisor.grantsOf(id);
|
||||
}
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params, { actingUserId });
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
// other-plugin events it subscribes to (so the host can route fan-out).
|
||||
const exportNames = Object.keys((def.exports ?? {}) as Record<string, unknown>);
|
||||
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 } });
|
||||
|
||||
@@ -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<string, Pending>; // host→child invokes awaiting a response
|
||||
invocations: Map<string, number | undefined>; // 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<string> {
|
||||
const sup = this.running.get(id);
|
||||
return sup && sup.status === 'active' ? sup.granted : new Set<string>();
|
||||
}
|
||||
|
||||
/** 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')
|
||||
: [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user