diff --git a/server/src/nest/plugins/contributions/plugin-contributions.module.ts b/server/src/nest/plugins/contributions/plugin-contributions.module.ts index 95840bc7a..f27406576 100644 --- a/server/src/nest/plugins/contributions/plugin-contributions.module.ts +++ b/server/src/nest/plugins/contributions/plugin-contributions.module.ts @@ -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 {} diff --git a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts new file mode 100644 index 000000000..0b402b5fb --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts @@ -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 { + 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.'); + } +} diff --git a/server/src/nest/plugins/mcp-tool-schema.ts b/server/src/nest/plugins/mcp-tool-schema.ts index caa67324b..4624f165d 100644 --- a/server/src/nest/plugins/mcp-tool-schema.ts +++ b/server/src/nest/plugins/mcp-tool-schema.ts @@ -197,13 +197,18 @@ export function buildToolInputSchema(normalised: Record | 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). + */ +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([ diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 05ddda883..b678398fc 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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; + 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 []; diff --git a/server/tests/unit/plugins/plugin-mcp-tools.test.ts b/server/tests/unit/plugins/plugin-mcp-tools.test.ts new file mode 100644 index 000000000..a834e7eed --- /dev/null +++ b/server/tests/unit/plugins/plugin-mcp-tools.test.ts @@ -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; + implemented?: Record; + grants?: Record; + callTool?: ReturnType; + 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 = {}; + const implemented: Record = {}; + 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 = {}; + 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); + }); +});