mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
/**
|
|
* 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;
|
|
// 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, dbs, 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<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(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<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', 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);
|
|
});
|
|
});
|