feat(plugins): advertise plugin tools on the MCP server

The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.

A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.

The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.

Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.

The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.

mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.

Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.

MCPTOOLS-001 to MCPTOOLS-019.
This commit is contained in:
Renzo Beux
2026-08-28 13:47:22 -03:00
committed by Maurice
parent c6027f9e68
commit 21fd04647f
5 changed files with 466 additions and 9 deletions
@@ -5,6 +5,7 @@ import { JourneyDomainModule } from '../../journey/journey-domain.module';
import { PlaceDetailsController } from './place-details.controller';
import { TripWarningsController } from './trip-warnings.controller';
import { TripWarningsMcp } from './trip-warnings.mcp';
import { PluginMcpToolsService } from './plugin-mcp-tools.service';
import { ViewContributionsController } from './view-contributions.controller';
import { TripCardContributionsController } from './trip-card-contributions.controller';
import { PluginPhotosController } from './plugin-photos.controller';
@@ -47,9 +48,13 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller';
AtlasLayersController,
JournalEntryRowsController,
],
// The only contribution with an MCP counterpart so far. It belongs to this module
// rather than to the trip read model because the plugin runtime and the trip
// aggregate already import each other's modules; see trip-warnings.mcp.ts.
providers: [TripWarningsMcp],
// The contributions with an MCP counterpart. TripWarningsMcp belongs to this
// module rather than to the trip read model because the plugin runtime and the
// trip aggregate already import each other's modules; see trip-warnings.mcp.ts.
//
// PluginMcpToolsService owns the process-level tool source. It lives here, and
// not on PluginRuntimeService beside the other sinks, because it needs
// PluginHooks and PluginHooks injects PluginRuntimeService.
providers: [TripWarningsMcp, PluginMcpToolsService],
})
export class PluginContributionsModule {}
@@ -0,0 +1,199 @@
/**
* Advertises plugin-published tools on every MCP session, and dispatches the
* calls back into the plugin that published them.
*
* Shaped like trip-warnings.mcp.ts, the other place the plugins domain meets
* MCP: gate on the kill switch, fan out over providersOf, and let a failing
* provider contribute nothing rather than fail the surface.
*
* The source runs inside the /mcp initialize request, so it is synchronous and
* reads only state already in memory or in SQLite. Nothing here waits on a
* child process.
*/
import { Injectable, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { RuntimeEnvService } from '../../app-config/runtime-env.service';
import { isDemoUserId } from '../../common/demo-write';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import { PluginRuntimeService } from '../plugin-runtime.service';
import {
MCP_TOOLS_MAX,
MCP_TOOLS_TOTAL_MAX,
buildToolInputSchema,
clampToolAnnotations,
mcpToolName,
} from '../mcp-tool-schema';
import { sanitiseAssistantText } from '../text-sanitize';
import { setPluginMcpToolSource } from '../../../plugin-mcp-tools';
import { demoDenied, errorResult, ok, type McpContext, type McpDynamicTool, type McpTextResult } from '../../../nest-mcp';
/** The hook a plugin implements to publish tools. */
const HOOK = 'mcpToolProvider';
/**
* Ceiling on one tool result, in characters. A plugin must not be able to flood
* the assistant's context, and a result this large is a bug on the plugin's side
* rather than something a model can use.
*/
const RESULT_MAX = 64 * 1024;
/** Error text a plugin produced, bounded before it reaches the model. */
const RESULT_ERROR_MAX = 300;
@Injectable()
export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDestroy {
constructor(
private readonly hooks: PluginHooks,
private readonly runtime: PluginRuntimeService,
private readonly env: RuntimeEnvService,
private readonly dbs: DatabaseService,
) {}
// The sink lives here rather than on PluginRuntimeService because the source
// needs PluginHooks, and PluginHooks injects PluginRuntimeService: owning it
// there would be a cycle. Cleared on destroy, or a torn-down buildApp() leaves
// a live source closing over a dead runtime and bleeds into the next suite.
onApplicationBootstrap(): void {
setPluginMcpToolSource((ctx) => this.mcpTools(ctx));
}
onModuleDestroy(): void {
setPluginMcpToolSource(null);
}
/**
* Every plugin tool this session may see. Synchronous, and never throws:
* nest-mcp contains a throwing source, but a per-plugin failure here should
* cost that plugin's tools and nothing else.
*/
mcpTools(_ctx: McpContext): McpDynamicTool[] {
if (!pluginsEnabled()) return [];
const out: McpDynamicTool[] = [];
let dropped = 0;
for (const id of this.hooks.providersOf(HOOK)) {
let tools: McpDynamicTool[];
try {
tools = this.toolsOf(id);
} catch {
// One plugin's bad row contributes nothing; the others still advertise.
continue;
}
for (const tool of tools) {
if (out.length >= MCP_TOOLS_TOTAL_MAX) {
dropped += 1;
continue;
}
out.push(tool);
}
}
if (dropped > 0) {
// Never silent: a truncated surface reads exactly like a plugin that
// published nothing.
console.warn(`[plugins] MCP tool surface capped at ${MCP_TOOLS_TOTAL_MAX}; dropped ${dropped}`);
}
return out;
}
/**
* One plugin's advertised tools: declared in the signed manifest AND reported
* by the running build.
*
* The manifest half is what makes the text auditable, since the child composes
* its loaded payload after onLoad and could otherwise change what it publishes
* on every restart with no version bump. Same two-sided shape as callPlugin's
* exports check.
*/
private toolsOf(pluginId: string): McpDynamicTool[] {
const declared = this.runtime.mcpToolCapabilities(pluginId);
if (!declared.length) return [];
const implemented = new Set(this.runtime.mcpToolsOf(pluginId));
const grants = this.runtime.grantsOf(pluginId);
const out: McpDynamicTool[] = [];
for (const tool of declared.slice(0, MCP_TOOLS_MAX)) {
if (!implemented.has(tool.name)) continue;
out.push({
options: {
name: mcpToolName(pluginId, tool.name),
// Re-sanitised even though parseCapabilities already did it: this blob
// was written by whatever parser was current at install time. Same
// reasoning as sanitizePluginChannels.
...(tool.title ? { title: sanitiseAssistantText(tool.title, 80) } : {}),
description: sanitiseAssistantText(tool.description, 1024),
inputSchema: buildToolInputSchema(tool.inputSchema),
annotations: clampToolAnnotations(tool.annotations, grants),
// No _meta: a plugin-authored channel straight into the client, that
// nothing reads. No outputSchema either, or the SDK would start
// demanding structuredContent the plugin never produces.
access: { group: 'plugins', mode: 'use' },
},
owner: this,
handler: (args, ctx) => this.invokeTool(pluginId, tool.name, args, ctx),
});
}
return out;
}
/**
* Run one tool and shape whatever comes back into an MCP result.
*
* The registry passes a handler's return value straight through and skips
* output validation without an outputSchema, so a plugin returning
* `{ ok: true, data }` would emit a result with no `content` key and every
* conforming client would error. The envelope is built here, always.
*/
private async invokeTool(pluginId: string, name: string, args: unknown, ctx: McpContext): Promise<McpTextResult> {
if (!pluginsEnabled()) return errorResult('Plugins are disabled on this server.');
// The plugins domain's first demo gate. The child has none of its own, and
// the ~40 isDemoUser checks elsewhere are per-handler, so it belongs here.
if (isDemoUserId(this.env, this.dbs, ctx.userId)) return demoDenied();
try {
const raw = await this.hooks.callMcpTool(pluginId, { name, args: args ?? {} }, ctx.userId);
return toMcpTextResult(raw);
} catch (e) {
// A failed or timed-out invoke is a TOOL error, not a protocol one: the
// model has to see it and be able to try something else.
const message = e instanceof Error ? e.message : String(e);
return errorResult(`Plugin "${pluginId}" could not run "${name}": ${sanitiseAssistantText(message, RESULT_ERROR_MAX)}`);
}
}
}
/** True for a value already shaped like an MCP result the SDK would accept. */
function isTextResult(v: unknown): v is McpTextResult {
return (
typeof v === 'object' &&
v !== null &&
Array.isArray((v as { content?: unknown }).content)
);
}
/**
* Whatever the plugin returned, as a result the SDK and every client accept.
* Exported for the tests, which is where the shapes are enumerated.
*/
export function toMcpTextResult(raw: unknown): McpTextResult {
if (isTextResult(raw)) {
const content = raw.content
.filter((c): c is { type: 'text'; text: string } => !!c && (c as { type?: unknown }).type === 'text')
.map((c) => ({ type: 'text' as const, text: String(c.text ?? '').slice(0, RESULT_MAX) }));
// A content array we cannot read is worse than none: fall through and
// serialise the whole thing rather than emit an empty result.
if (content.length) return { content, ...(raw.isError === true ? { isError: true as const } : {}) };
}
if (typeof raw === 'string') return { content: [{ type: 'text', text: raw.slice(0, RESULT_MAX) }] };
try {
const result = ok(raw);
const [first] = result.content;
return { content: [{ type: 'text', text: String(first?.text ?? '').slice(0, RESULT_MAX) }] };
} catch {
// JSON.stringify throws on a cycle or a BigInt.
return errorResult('The plugin returned a value that could not be serialised.');
}
}
+8 -3
View File
@@ -197,13 +197,18 @@ export function buildToolInputSchema(normalised: Record<string, unknown> | undef
return (normalised ? base.meta(normalised) : base) as unknown as McpZodSchema;
}
/** The four hints the MCP spec defines. Anything else a plugin sends is dropped. */
export interface McpToolAnnotations {
/**
* The four hints the MCP spec defines. Anything else a plugin sends is dropped.
*
* A type alias rather than an interface so it keeps an implicit index signature
* and stays assignable to ToolOptions.annotations (Record<string, unknown>).
*/
export type McpToolAnnotations = {
readOnlyHint: boolean;
destructiveHint: boolean;
idempotentHint: boolean;
openWorldHint: boolean;
}
};
/** Grants that mean the plugin can change something outside its own sandbox. */
const SIDE_EFFECT_EXACT = new Set([
@@ -20,7 +20,7 @@ import { ForbiddenResource } from './host/rpc-host';
import { removePluginData } from './host/plugin-data.service';
import { isKnownPermission } from './protocol/envelope';
import { discoverPlugins } from './install/discovery';
import { parseJsonText, parseManifest } from './install/manifest';
import { parseJsonText, parseManifest, parseMcpToolCapabilities } from './install/manifest';
import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths';
@@ -29,7 +29,7 @@ import { hostSatisfies, hostVersion } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { AuditService } from '../audit/audit.service';
import { AddonsService } from '../addons/addons.service';
import type { PluginDependency } from './install/manifest';
import type { McpToolCapability, PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
@@ -1147,6 +1147,30 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
/**
* A plugin's declared `capabilities.mcpTools`, re-validated.
*
* capabilityList below cannot carry these: it returns string[]. And the blob
* is re-parsed rather than JSON.parse'd straight through, because it was
* written by whatever version of parseCapabilities was current at install
* time and the caps in it are the security control.
*
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
*/
mcpToolCapabilities(id: string): McpToolCapability[] {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
try {
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
if (c.mcpTools === undefined) return [];
return parseMcpToolCapabilities(c.mcpTools);
} catch {
// A malformed or now-invalid blob means "advertises nothing", never a throw
// on the session-creation path.
return [];
}
}
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
@@ -0,0 +1,224 @@
/**
* PluginMcpToolsService — MCPTOOLS-001 to MCPTOOLS-018.
*
* The advertise path: what a session is shown, and what happens when a plugin
* misbehaves on the way back. The text and schema rules themselves are pinned
* in mcp-tool-schema.test.ts; this file covers the intersection, the gates, the
* caps and the result envelope.
*/
import { PluginMcpToolsService, toMcpTextResult } from '../../../src/nest/plugins/contributions/plugin-mcp-tools.service';
import type { McpContext } from '../../../src/nest-mcp';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const ctx = { userId: 5, scopes: null, isStaticToken: false } as McpContext;
const weatherTool = {
name: 'forecast',
title: 'Forecast',
description: 'Gets the weather.',
inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
};
interface Wiring {
providers?: string[];
declared?: Record<string, unknown[]>;
implemented?: Record<string, string[]>;
grants?: Record<string, string[]>;
callTool?: ReturnType<typeof vi.fn>;
demo?: boolean;
}
function makeService(w: Wiring = {}) {
const callTool = w.callTool ?? vi.fn(async () => ({ ok: true }));
const hooks = {
providersOf: vi.fn((hook: string) => (hook === 'mcpToolProvider' ? (w.providers ?? ['weather']) : [])),
callMcpTool: callTool,
};
const runtime = {
mcpToolCapabilities: vi.fn((id: string) => (w.declared ?? { weather: [weatherTool] })[id] ?? []),
mcpToolsOf: vi.fn((id: string) => (w.implemented ?? { weather: ['forecast'] })[id] ?? []),
grantsOf: vi.fn((id: string) => new Set((w.grants ?? {})[id] ?? [])),
};
const env = {} as never;
const dbs = {} as never;
const svc = new PluginMcpToolsService(hooks as never, runtime as never, env, dbs);
return { svc, hooks, runtime, callTool };
}
let pluginsOn = true;
let demoUser = false;
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsOn }));
vi.mock('../../../src/nest/common/demo-write', () => ({ isDemoUserId: () => demoUser }));
beforeEach(() => {
pluginsOn = true;
demoUser = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('the advertised surface', () => {
it('MCPTOOLS-001: advertises a declared and implemented tool under the prefixed name', () => {
const { svc } = makeService();
const tools = svc.mcpTools(ctx);
expect(tools).toHaveLength(1);
expect(tools[0].options.name).toBe('plugin_weather_forecast');
expect(tools[0].options.description).toBe('Gets the weather.');
expect(tools[0].options.access).toEqual({ group: 'plugins', mode: 'use' });
});
it('MCPTOOLS-002: drops a tool the manifest declares but the build does not implement', () => {
const { svc } = makeService({ implemented: { weather: [] } });
expect(svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', () => {
// The manifest is signed and re-consented; the loaded report is neither.
const { svc } = makeService({ declared: { weather: [] }, implemented: { weather: ['sneaky'] } });
expect(svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-004: a plugin without the grant never reaches the surface', () => {
// providersOf already filters on active AND reported AND granted.
const { svc, hooks } = makeService({ providers: [] });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).toHaveBeenCalledWith('mcpToolProvider');
});
it('MCPTOOLS-005: the kill switch empties the surface', () => {
pluginsOn = false;
const { svc, hooks } = makeService();
expect(svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).not.toHaveBeenCalled();
});
it('MCPTOOLS-006: one failing plugin costs only its own tools', () => {
const { svc } = makeService({
providers: ['broken', 'weather'],
declared: {
get weather() { return [weatherTool]; },
get broken(): never { throw new Error('bad row'); },
} as never,
implemented: { weather: ['forecast'], broken: ['x'] },
});
const tools = svc.mcpTools(ctx);
expect(tools.map((t) => t.options.name)).toEqual(['plugin_weather_forecast']);
});
it('MCPTOOLS-007: caps the tools one plugin may contribute', () => {
const many = Array.from({ length: 12 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const { svc } = makeService({
declared: { weather: many },
implemented: { weather: many.map((t) => t.name) },
});
expect(svc.mcpTools(ctx)).toHaveLength(8);
});
it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const eight = Array.from({ length: 8 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const providers = ['p1', 'p2', 'p3', 'p4', 'p5'];
const declared: Record<string, unknown[]> = {};
const implemented: Record<string, string[]> = {};
for (const p of providers) {
declared[p] = eight;
implemented[p] = eight.map((t) => t.name);
}
const { svc } = makeService({ providers, declared, implemented });
expect(svc.mcpTools(ctx)).toHaveLength(32);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dropped'));
});
it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', () => {
const claiming = [{ ...weatherTool, annotations: { readOnlyHint: true } }];
const { svc } = makeService({
declared: { weather: claiming },
grants: { weather: ['db:write:trips'] },
});
expect(svc.mcpTools(ctx)[0].options.annotations).toMatchObject({ readOnlyHint: false });
});
it('MCPTOOLS-010: never advertises _meta or an outputSchema', () => {
const { svc } = makeService({ declared: { weather: [{ ...weatherTool, _meta: { x: 1 } }] } });
const options = svc.mcpTools(ctx)[0].options;
expect(options._meta).toBeUndefined();
expect(options.outputSchema).toBeUndefined();
});
});
describe('invoking a tool', () => {
const invoke = async (svc: PluginMcpToolsService, args: unknown = { city: 'Lisbon' }) => {
const tool = svc.mcpTools(ctx)[0];
return (await tool.handler(args, ctx)) as { content: Array<{ text: string }>; isError?: boolean };
};
it('MCPTOOLS-011: passes the plugin-local name and binds the requesting user', async () => {
const { svc, callTool } = makeService();
await invoke(svc);
expect(callTool).toHaveBeenCalledWith('weather', { name: 'forecast', args: { city: 'Lisbon' } }, 5);
});
it('MCPTOOLS-012: wraps a bare object into a result with a content key', async () => {
// The registry passes a handler's value straight through and skips output
// validation without an outputSchema, so an unwrapped object would reach
// the client with no content key and every conforming one would error.
const { svc } = makeService({ callTool: vi.fn(async () => ({ ok: true, data: [1, 2] })) });
const res = await invoke(svc);
expect(Array.isArray(res.content)).toBe(true);
expect(JSON.parse(res.content[0].text)).toEqual({ ok: true, data: [1, 2] });
});
it('MCPTOOLS-013: maps a throwing plugin to a tool error, not a protocol fault', async () => {
const { svc } = makeService({ callTool: vi.fn(async () => { throw new Error('upstream 503'); }) });
const res = await invoke(svc);
expect(res.isError).toBe(true);
expect(res.content[0].text).toContain('upstream 503');
});
it('MCPTOOLS-014: refuses in demo mode without ever reaching the plugin', async () => {
demoUser = true;
const { svc, callTool } = makeService();
const res = await invoke(svc);
expect(res.isError).toBe(true);
expect(callTool).not.toHaveBeenCalled();
});
it('MCPTOOLS-015: refuses once the kill switch goes off mid-session', async () => {
const { svc, callTool } = makeService();
const tool = svc.mcpTools(ctx)[0];
pluginsOn = false;
const res = (await tool.handler({}, ctx)) as { isError?: boolean };
expect(res.isError).toBe(true);
expect(callTool).not.toHaveBeenCalled();
});
});
describe('toMcpTextResult', () => {
it('MCPTOOLS-016: preserves a well-formed result, isError included', () => {
const given = { content: [{ type: 'text', text: 'hi' }], isError: true };
expect(toMcpTextResult(given)).toEqual(given);
});
it('MCPTOOLS-017: wraps a bare string', () => {
expect(toMcpTextResult('hello')).toEqual({ content: [{ type: 'text', text: 'hello' }] });
});
it('MCPTOOLS-018: survives a value that cannot be serialised', () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const res = toMcpTextResult(cyclic) as { isError?: boolean };
expect(res.isError).toBe(true);
});
it('MCPTOOLS-019: caps a flood so a plugin cannot fill the assistant context', () => {
const huge = 'x'.repeat(200_000);
const res = toMcpTextResult(huge);
expect(res.content[0].text.length).toBeLessThanOrEqual(64 * 1024);
});
});