mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
/**
|
|
* McpAttachOptions.dynamicTools — DYNTOOL-001 to DYNTOOL-012.
|
|
*
|
|
* The per-session seam that lets a host contribute tools it did not decorate.
|
|
* Two properties carry the weight here and both are security properties, not
|
|
* conveniences: a contributed tool can never take a registered entry's name,
|
|
* and nothing a contributor does can make attach() throw. Hosts call attach()
|
|
* outside their request try block, so an escape is a 500 on every initialize.
|
|
*/
|
|
import {
|
|
McpController,
|
|
Tool,
|
|
createTestRegistry,
|
|
type McpAccessPolicy,
|
|
type McpDynamicTool,
|
|
type McpContext,
|
|
} from '../../../src/nest-mcp';
|
|
import { createAttachHarness, type TestCtx } from './harness';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { z } from 'zod';
|
|
|
|
@McpController()
|
|
class Builtins {
|
|
@Tool({
|
|
name: 'read_thing',
|
|
description: 'A registered read tool.',
|
|
inputSchema: { id: z.number() },
|
|
access: { group: 'thing', mode: 'read' } as never,
|
|
})
|
|
readThing(): { content: Array<{ type: 'text'; text: string }> } {
|
|
return { content: [{ type: 'text', text: 'builtin read' }] };
|
|
}
|
|
|
|
@Tool({
|
|
name: 'write_thing',
|
|
description: 'A registered write tool.',
|
|
inputSchema: { id: z.number() },
|
|
access: { group: 'thing', mode: 'write' } as never,
|
|
})
|
|
writeThing(): { content: Array<{ type: 'text'; text: string }> } {
|
|
return { content: [{ type: 'text', text: 'builtin write' }] };
|
|
}
|
|
}
|
|
|
|
/** No access marker, so it registers on a registry with no policy configured. */
|
|
@McpController()
|
|
class Ungated {
|
|
@Tool({ name: 'ungated_thing', description: 'Needs no policy.' })
|
|
ungatedThing(): { content: Array<{ type: 'text'; text: string }> } {
|
|
return { content: [{ type: 'text', text: 'ungated' }] };
|
|
}
|
|
}
|
|
|
|
/** Grants read to everyone, write only to a ctx that asked for it. */
|
|
const policy: McpAccessPolicy = ({ mode }, ctx) => (mode === 'read' ? true : (ctx as TestCtx).canWrite === true);
|
|
|
|
function makeRegistry() {
|
|
return createTestRegistry([new Builtins()], { accessPolicy: policy });
|
|
}
|
|
|
|
function dynamicTool(over: Partial<McpDynamicTool> = {}): McpDynamicTool {
|
|
return {
|
|
options: {
|
|
name: 'plugin_demo_echo',
|
|
description: 'Echoes.',
|
|
inputSchema: { value: z.string() },
|
|
access: { group: 'thing', mode: 'read' } as never,
|
|
...(over.options ?? {}),
|
|
},
|
|
handler: () => ({ content: [{ type: 'text', text: 'dynamic' }] }),
|
|
...(over.handler ? { handler: over.handler } : {}),
|
|
...(over.owner ? { owner: over.owner } : {}),
|
|
} as McpDynamicTool;
|
|
}
|
|
|
|
const listNames = async (client: { listTools: () => Promise<{ tools: Array<{ name: string }> }> }) =>
|
|
(await client.listTools()).tools.map((t) => t.name);
|
|
|
|
let warn: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
warn.mockRestore();
|
|
});
|
|
|
|
describe('dynamic tools: the happy path', () => {
|
|
it('DYNTOOL-001: registers a contributed tool and calls it', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{ canWrite: true },
|
|
{
|
|
dynamicTools: () => [dynamicTool()],
|
|
},
|
|
);
|
|
|
|
expect(await listNames(h.client)).toContain('plugin_demo_echo');
|
|
const res = await h.client.callTool({ name: 'plugin_demo_echo', arguments: { value: 'x' } });
|
|
expect((res.content as Array<{ text: string }>)[0].text).toBe('dynamic');
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-002: binds owner as `this` on the handler', async () => {
|
|
const owner = { marker: 'the-owner' };
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
dynamicTools: () => [
|
|
dynamicTool({
|
|
owner,
|
|
handler: function (this: typeof owner) {
|
|
return { content: [{ type: 'text', text: this.marker }] };
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
);
|
|
|
|
const res = await h.client.callTool({ name: 'plugin_demo_echo', arguments: { value: 'x' } });
|
|
expect((res.content as Array<{ text: string }>)[0].text).toBe('the-owner');
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-003: hands the owner to when(ctx, self)', async () => {
|
|
const owner = { enabled: false };
|
|
const seen: object[] = [];
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
dynamicTools: () => [
|
|
dynamicTool({
|
|
owner,
|
|
options: {
|
|
name: 'plugin_demo_gated',
|
|
description: 'Gated.',
|
|
access: { group: 'thing', mode: 'read' },
|
|
when: (_ctx, self) => {
|
|
seen.push(self);
|
|
return (self as typeof owner).enabled;
|
|
},
|
|
} as unknown as McpDynamicTool['options'],
|
|
}),
|
|
],
|
|
},
|
|
);
|
|
|
|
expect(seen).toEqual([owner]);
|
|
expect(await listNames(h.client)).not.toContain('plugin_demo_gated');
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-004: fires onInvoke, so the host audit trail sees the call', async () => {
|
|
const onInvoke = vi.fn();
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
onInvoke,
|
|
dynamicTools: () => [dynamicTool()],
|
|
},
|
|
);
|
|
|
|
await h.client.callTool({ name: 'plugin_demo_echo', arguments: { value: 'x' } });
|
|
|
|
expect(onInvoke).toHaveBeenCalledWith({ kind: 'tool', name: 'plugin_demo_echo' });
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-005: registers contributed tools after every registered entry', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{ canWrite: true },
|
|
{
|
|
dynamicTools: () => [dynamicTool()],
|
|
},
|
|
);
|
|
|
|
const names = await listNames(h.client);
|
|
expect(names.indexOf('plugin_demo_echo')).toBeGreaterThan(names.indexOf('write_thing'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('dynamic tools: name reservation', () => {
|
|
it('DYNTOOL-006: cannot take the name of a registered entry', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{ canWrite: true },
|
|
{
|
|
dynamicTools: () => [dynamicTool({ options: { name: 'read_thing' } as McpDynamicTool['options'] })],
|
|
},
|
|
);
|
|
|
|
const names = await listNames(h.client);
|
|
expect(names.filter((n) => n === 'read_thing')).toHaveLength(1);
|
|
const res = await h.client.callTool({ name: 'read_thing', arguments: { id: 1 } });
|
|
expect((res.content as Array<{ text: string }>)[0].text).toBe('builtin read');
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-007: reserves against every registered name, not the ones this session got', async () => {
|
|
// canWrite is false, so write_thing is NOT attached for this session. The
|
|
// name is still reserved: otherwise a contributor could occupy a built-in
|
|
// for exactly the callers who hold no scope for it.
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{ canWrite: false },
|
|
{
|
|
dynamicTools: () => [dynamicTool({ options: { name: 'write_thing' } as McpDynamicTool['options'] })],
|
|
},
|
|
);
|
|
|
|
expect(await listNames(h.client)).not.toContain('write_thing');
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('reserved by a registered entry'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-008: first wins on a duplicate within one source, and a denied entry still holds its name', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
dynamicTools: () => [
|
|
// Denied by `when`, but it has claimed the name.
|
|
dynamicTool({
|
|
options: {
|
|
name: 'plugin_demo_echo',
|
|
description: 'First, denied.',
|
|
access: { group: 'thing', mode: 'read' },
|
|
when: () => false,
|
|
} as unknown as McpDynamicTool['options'],
|
|
}),
|
|
dynamicTool({ options: { name: 'plugin_demo_echo', description: 'Second.' } as McpDynamicTool['options'] }),
|
|
],
|
|
},
|
|
);
|
|
|
|
expect(await listNames(h.client)).not.toContain('plugin_demo_echo');
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('duplicate name in this source'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('dynamic tools: containment', () => {
|
|
it('DYNTOOL-009: a throwing source costs the dynamic tools, not the session', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{ canWrite: true },
|
|
{
|
|
dynamicTools: () => {
|
|
throw new Error('the runtime is down');
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(await listNames(h.client)).toEqual(['read_thing', 'write_thing']);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('the runtime is down'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-010: one bad entry is skipped and the rest still register', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
dynamicTools: () => [
|
|
{
|
|
options: {
|
|
name: 'plugin_demo_broken',
|
|
description: 'No handler.',
|
|
access: { group: 'thing', mode: 'read' },
|
|
},
|
|
handler: undefined,
|
|
} as unknown as McpDynamicTool,
|
|
dynamicTool(),
|
|
],
|
|
},
|
|
);
|
|
|
|
const names = await listNames(h.client);
|
|
expect(names).not.toContain('plugin_demo_broken');
|
|
expect(names).toContain('plugin_demo_echo');
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-011: an entry with no access marker is skipped, never registered ungated', async () => {
|
|
const h = await createAttachHarness(
|
|
makeRegistry(),
|
|
{},
|
|
{
|
|
dynamicTools: () => [
|
|
{
|
|
options: { name: 'plugin_demo_ungated', description: 'No access.' },
|
|
handler: () => ({ content: [] }),
|
|
} as unknown as McpDynamicTool,
|
|
],
|
|
},
|
|
);
|
|
|
|
expect(await listNames(h.client)).not.toContain('plugin_demo_ungated');
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('access is required'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
|
|
it('DYNTOOL-012: declarative access with no configured policy is skipped, not thrown', async () => {
|
|
// The second live throw site. A registered entry only avoids it because
|
|
// validate() pre-checks it at boot; a per-session source has no boot.
|
|
// The registry holds one marker-free entry so the server still advertises
|
|
// a tools capability, which is what makes the skip observable.
|
|
const noPolicy = createTestRegistry([new Ungated()]);
|
|
const h = await createAttachHarness(noPolicy, {}, { dynamicTools: () => [dynamicTool()] });
|
|
|
|
expect(await listNames(h.client)).toEqual(['ungated_thing']);
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessPolicy was configured'));
|
|
|
|
await h.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('dynamic tools: absent', () => {
|
|
it('DYNTOOL-013: attach without a source behaves exactly as before', async () => {
|
|
const h = await createAttachHarness(makeRegistry(), { canWrite: true });
|
|
|
|
expect(await listNames(h.client)).toEqual(['read_thing', 'write_thing']);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
|
|
await h.cleanup();
|
|
});
|
|
});
|
|
|
|
/** Kept honest: the ctx handed to the source is the session's own. */
|
|
describe('dynamic tools: context', () => {
|
|
it('DYNTOOL-014: the source is consulted once, with this session ctx', async () => {
|
|
const source = vi.fn((_ctx: McpContext) => [dynamicTool()]);
|
|
const h = await createAttachHarness(makeRegistry(), { userId: 42, canWrite: true }, { dynamicTools: source });
|
|
|
|
expect(source).toHaveBeenCalledTimes(1);
|
|
expect(source.mock.calls[0][0]).toMatchObject({ userId: 42 });
|
|
|
|
await h.cleanup();
|
|
});
|
|
});
|