/** * 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; // Plan 3i Task 3: DemoService, stubbed the same shape the removed // vi.mock('.../common/demo-write', ...) provided — isDemoUserId() reads // the module-level `demoUser` flag at call time. const demo = { isDemoUserId: () => Promise.resolve(demoUser) } as never; const svc = new PluginMcpToolsService(hooks as never, runtime as never, env, demo); return { svc, hooks, runtime, callTool }; } let pluginsOn = true; let demoUser = false; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsOn })); 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', async () => { const { svc } = makeService(); const tools = await 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', async () => { const { svc } = makeService({ implemented: { weather: [] } }); expect(await svc.mcpTools(ctx)).toEqual([]); }); it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', async () => { // The manifest is signed and re-consented; the loaded report is neither. const { svc } = makeService({ declared: { weather: [] }, implemented: { weather: ['sneaky'] } }); expect(await svc.mcpTools(ctx)).toEqual([]); }); it('MCPTOOLS-004: a plugin without the grant never reaches the surface', async () => { // providersOf already filters on active AND reported AND granted. const { svc, hooks } = makeService({ providers: [] }); expect(await svc.mcpTools(ctx)).toEqual([]); expect(hooks.providersOf).toHaveBeenCalledWith('mcpToolProvider'); }); it('MCPTOOLS-005: the kill switch empties the surface', async () => { pluginsOn = false; const { svc, hooks } = makeService(); expect(await svc.mcpTools(ctx)).toEqual([]); expect(hooks.providersOf).not.toHaveBeenCalled(); }); it('MCPTOOLS-006: one failing plugin costs only its own tools', async () => { 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 = await svc.mcpTools(ctx); expect(tools.map((t) => t.options.name)).toEqual(['plugin_weather_forecast']); }); it('MCPTOOLS-007: caps the tools one plugin may contribute', async () => { 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(await svc.mcpTools(ctx)).toHaveLength(8); }); it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', async () => { 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(await svc.mcpTools(ctx)).toHaveLength(32); expect(warn).toHaveBeenCalledWith(expect.stringContaining('dropped')); }); it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', async () => { const claiming = [{ ...weatherTool, annotations: { readOnlyHint: true } }]; const { svc } = makeService({ declared: { weather: claiming }, grants: { weather: ['db:write:trips'] }, }); expect((await svc.mcpTools(ctx))[0].options.annotations).toMatchObject({ readOnlyHint: false }); }); it('MCPTOOLS-010: never advertises _meta or an outputSchema', async () => { const { svc } = makeService({ declared: { weather: [{ ...weatherTool, _meta: { x: 1 } }] } }); const options = (await 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 = (await 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, via the injected DemoService (Plan 3i Task 3 survivor site), with the exact canned refusal body', async () => { demoUser = true; const { svc, callTool } = makeService(); const res = await invoke(svc); expect(res.isError).toBe(true); expect(res.content).toEqual([{ type: 'text', text: 'Write operations are disabled in demo mode.' }]); expect(callTool).not.toHaveBeenCalled(); }); it('MCPTOOLS-015: refuses once the kill switch goes off mid-session', async () => { const { svc, callTool } = makeService(); const tool = (await 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', async () => { const given = { content: [{ type: 'text', text: 'hi' }], isError: true }; expect(toMcpTextResult(given)).toEqual(given); }); it('MCPTOOLS-017: wraps a bare string', async () => { expect(toMcpTextResult('hello')).toEqual({ content: [{ type: 'text', text: 'hello' }] }); }); it('MCPTOOLS-018: survives a value that cannot be serialised', async () => { 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', async () => { const huge = 'x'.repeat(200_000); const res = toMcpTextResult(huge); expect(res.content[0].text.length).toBeLessThanOrEqual(64 * 1024); }); it('MCPTOOLS-020: the budget covers the WHOLE result, not each block', async () => { // Per-block slicing is not a limit: 100 blocks of the maximum size is 100 // times the maximum. This shape produced 6.5M characters before the fix. const many = { content: Array.from({ length: 100 }, () => ({ type: 'text', text: 'x'.repeat(200_000) })) }; const res = toMcpTextResult(many); const total = res.content.reduce((n, c) => n + c.text.length, 0); expect(total).toBeLessThan(70 * 1024); }); it('MCPTOOLS-021: caps the number of content blocks and says it truncated', async () => { const many = { content: Array.from({ length: 500 }, (_, i) => ({ type: 'text', text: `b${i}` })) }; const res = toMcpTextResult(many); expect(res.content.length).toBeLessThanOrEqual(33); expect(res.content[res.content.length - 1].text).toContain('truncated'); }); it('MCPTOOLS-022: bounds a huge object without serialising it whole first', async () => { const fat = { rows: Array.from({ length: 20_000 }, (_, i) => ({ i, blob: 'y'.repeat(500) })) }; const res = toMcpTextResult(fat); const total = res.content.reduce((n, c) => n + c.text.length, 0); expect(total).toBeLessThan(70 * 1024); }); });