Files
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

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();
});
});