diff --git a/.changeset/mcp-tool-filtering.md b/.changeset/mcp-tool-filtering.md new file mode 100644 index 00000000..37909f13 --- /dev/null +++ b/.changeset/mcp-tool-filtering.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Added MCP tool filtering via `mcp: false` on commands and root-level `mcp.tools` include/exclude patterns. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 88000402..e8bdc26f 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -368,10 +368,16 @@ test('command mcp metadata accepts instructions and annotations', () => { run: () => ({ ok: true }), }) + Cli.create('test').command('hidden', { + mcp: false, + run: () => ({ ok: true }), + }) + Cli.create('test', { mcp: { instructions: 'Use this server for test commands.', stateless: false, + tools: { include: ['read_*'], exclude: ['*_secret'] }, // @ts-expect-error -- annotations belong on command definitions annotations: { readOnlyHint: true }, }, diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 9a1fb0d6..1358fd65 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -2967,6 +2967,28 @@ describe('built-in commands', () => { }) }) + test('mcp doctor applies root MCP tool filters', async () => { + const cli = Cli.create('test', { + version: '1.0.0', + mcp: { tools: { exclude: ['secret_*'] } }, + }) + cli.command('docs_list', { description: 'Docs', run: () => ({ ok: true }) }) + cli.command('secret_list', { description: 'Secret', run: () => ({ ok: true }) }) + + const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json']) + const result = JSON.parse(output) + + expect(exitCode).toBeUndefined() + expect(result.tools).toMatchInlineSnapshot(` + [ + { + "description": "Docs", + "name": "docs_list", + }, + ] + `) + }) + test('mcp doctor forwards MCP instructions to the smoke test server', async () => { const spy = mockMcpServeResponses([ { @@ -5725,6 +5747,62 @@ describe('fetch', () => { `) }) + test('POST /mcp omits commands with mcp false while command routes still work', async () => { + const cli = Cli.create('test', { version: '1.0.0' }) + cli.command('public', { run: () => ({ public: true }) }) + cli.command('secret', { mcp: false, run: () => ({ secret: true }) }) + + const { sessionId } = await initSession(cli) + const res = await mcpRequest( + cli, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + sessionId, + ) + const body = await res.json() + const route = await fetchJson(cli, new Request('http://localhost/secret')) + const argv = await serve(cli, ['secret', '--json']) + + expect(body.result.tools.map((tool: any) => tool.name)).toMatchInlineSnapshot(` + [ + "public", + ] + `) + expect(route.body.data).toMatchInlineSnapshot(` + { + "secret": true, + } + `) + expect(JSON.parse(argv.output)).toMatchInlineSnapshot(` + { + "secret": true, + } + `) + }) + + test('POST /mcp filters tools with root include and exclude patterns', async () => { + const cli = Cli.create('test', { + version: '1.0.0', + mcp: { tools: { include: ['docs_*'], exclude: ['*_secret'] } }, + }) + cli.command('docs_list', { run: () => null }) + cli.command('docs_secret', { run: () => null }) + cli.command('users_list', { run: () => null }) + + const { sessionId } = await initSession(cli) + const res = await mcpRequest( + cli, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + sessionId, + ) + const body = await res.json() + + expect(body.result.tools.map((tool: any) => tool.name)).toMatchInlineSnapshot(` + [ + "docs_list", + ] + `) + }) + test('GET /mcp returns method not allowed in stateless mode', async () => { const cli = mcpCli() const res = await cli.fetch( diff --git a/src/Cli.ts b/src/Cli.ts index 5f392ce9..6b7fbcf7 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -97,6 +97,8 @@ export type Cli< openapi?: Openapi.OpenAPISource | undefined openapiConfig?: Openapi.Config | undefined outputPolicy?: OutputPolicy | undefined + /** Set to `false` to hide this command group from MCP clients. */ + mcp?: false | undefined }, ): Cli } @@ -247,6 +249,7 @@ export function create( const pending: Promise[] = [] const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0', { stateless: def.mcp?.stateless, + tools: def.mcp?.tools, }) if (def.openapi && rootFetch) { @@ -287,6 +290,7 @@ export function create( description: def.description, commands: generated as Map, ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined), + ...(def.mcp === false ? { mcp: false } : undefined), } as InternalGroup assertNoGlobalOptionConflicts(nameOrCli, entry, toGlobals.get(cli)) commands.set(nameOrCli, entry) @@ -563,6 +567,8 @@ export declare namespace create { instructions?: string | undefined /** Disable HTTP MCP session management. Defaults to `true`. */ stateless?: boolean | undefined + /** Filters which command tools are exposed to MCP clients. */ + tools?: Mcp.ToolFilter | undefined } | undefined /** Options for the built-in `skills add` command. */ @@ -694,6 +700,7 @@ async function serveImpl( vars: options.vars, version: options.version, ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined), + ...(options.mcp?.tools ? { tools: options.mcp.tools } : undefined), }) return } @@ -1809,7 +1816,7 @@ function createMcpHttpHandler( const server = new McpServer({ name, version }) - for (const tool of Mcp.collectTools(commands, [])) { + for (const tool of Mcp.collectTools(commands, [], [], options.tools)) { const mergedShape: Record = { ...tool.command.args?.shape, ...tool.command.options?.shape, @@ -1855,6 +1862,8 @@ declare namespace createMcpHttpHandler { type Options = { /** Disable HTTP MCP session management. Defaults to `true`. */ stateless?: boolean | undefined + /** Filters which command tools are exposed to MCP clients. */ + tools?: Mcp.ToolFilter | undefined } } @@ -2410,6 +2419,7 @@ declare namespace serveImpl { command?: string | undefined instructions?: string | undefined stateless?: boolean | undefined + tools?: Mcp.ToolFilter | undefined } | undefined /** Banner config, called before root help. */ @@ -2753,6 +2763,7 @@ async function runMcpDoctor( vars: options.vars, version: options.version, ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined), + ...(options.mcp?.tools ? { tools: options.mcp.tools } : undefined), }).catch((error) => { serveError = error }) @@ -2899,6 +2910,7 @@ export type FetchSource = Fetch.Source type InternalGroup = { _group: true description?: string | undefined + mcp?: false | undefined middlewares?: MiddlewareHandler[] | undefined outputPolicy?: OutputPolicy | undefined commands: Map @@ -3457,7 +3469,10 @@ type SkillCommandSource = Pick< > function isDestructive(command: SkillCommandSource): boolean { - return command.destructive === true || command.mcp?.annotations?.destructiveHint === true + return ( + command.destructive === true || + (command.mcp !== false && command.mcp?.annotations?.destructiveHint === true) + ) } function appendDestructiveHint(hint: string | undefined): string { @@ -3632,6 +3647,8 @@ type CommandDefinition< hint?: string | undefined /** MCP-specific metadata exposed when this command is served as a tool. */ mcp?: + /** Set to `false` to hide this command from MCP clients. */ + | false | { /** Override the command name exposed to MCP clients. */ name?: string | undefined diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index 8a61c3f4..ac51cedb 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -71,13 +71,14 @@ const initParams = { async function mcpSession( commands: Map, messages: { method: string; params?: unknown; id?: number }[], + options: Omit = {}, ) { const input = new PassThrough() const output = new PassThrough() const chunks: string[] = [] output.on('data', (chunk) => chunks.push(chunk.toString())) - const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output }) + const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output, ...options }) for (const msg of messages) { const rpc = { jsonrpc: '2.0', ...msg } @@ -141,6 +142,65 @@ describe('Mcp', () => { expect(echoTool.inputSchema.required).toContain('message') }) + test('collectTools hides commands and groups with mcp false', () => { + const commands = createTestCommands() + commands.set('secret', { mcp: false, run: () => ({ ok: true }) }) + commands.set('hidden', { + _group: true, + mcp: false, + commands: new Map([['inside', { run: () => ({ ok: true }) }]]), + }) + + expect(Mcp.collectTools(commands, []).map((tool) => tool.name)).toMatchInlineSnapshot(` + [ + "echo", + "fail", + "greet_hello", + "ping", + "stream", + ] + `) + }) + + test('collectTools filters tools by include and exclude patterns', () => { + const commands = new Map([ + ['docs_list', { run: () => null }], + ['docs_secret', { run: () => null }], + ['users_list', { run: () => null }], + ]) + + expect( + Mcp.collectTools(commands, [], [], { include: ['docs_*'], exclude: ['*_secret'] }).map( + (tool) => tool.name, + ), + ).toMatchInlineSnapshot(` + [ + "docs_list", + ] + `) + }) + + test('stdio serve filters tools/list by configured patterns', async () => { + const commands = new Map([ + ['docs_list', { run: () => null }], + ['secret_list', { run: () => null }], + ]) + const [, res] = await mcpSession( + commands, + [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/list', params: {} }, + ], + { tools: { exclude: ['secret_*'] } }, + ) + + expect(res.result.tools.map((tool: any) => tool.name)).toMatchInlineSnapshot(` + [ + "docs_list", + ] + `) + }) + test('tools/list uses command MCP name and description overrides', async () => { const commands = new Map() commands.set('whoami', { diff --git a/src/Mcp.ts b/src/Mcp.ts index 74781628..b9d43bfe 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -26,7 +26,7 @@ export async function serve( options.instructions ? { instructions: options.instructions } : undefined, ) - for (const tool of collectTools(commands, [])) { + for (const tool of collectTools(commands, [], [], options.tools)) { const mergedShape: Record = { ...tool.command.args?.shape, ...tool.command.options?.shape, @@ -116,6 +116,8 @@ export declare namespace serve { version?: string | undefined /** Instructions describing how to use the server and its features. */ instructions?: string | undefined + /** Filters which command tools are exposed to MCP clients. */ + tools?: ToolFilter | undefined } } @@ -241,38 +243,75 @@ export type ToolAnnotations = { openWorldHint?: boolean | undefined } +/** MCP tool name filtering options. */ +export type ToolFilter = { + /** Tool name patterns to expose. Omitted means all tools. `*` matches any characters. */ + include?: string[] | undefined + /** Tool name patterns to hide. Excludes win over includes. `*` matches any characters. */ + exclude?: string[] | undefined +} + /** @internal Recursively collects leaf commands as tool entries. */ export function collectTools( commands: Map, prefix: string[], parentMiddlewares: MiddlewareHandler[] = [], + filter?: ToolFilter | undefined, +): ToolEntry[] { + const tools = filterTools(collectToolEntries(commands, prefix, parentMiddlewares), filter) + assertUniqueToolNames(tools) + return tools.sort((a, b) => a.name.localeCompare(b.name)) +} + +function collectToolEntries( + commands: Map, + prefix: string[], + parentMiddlewares: MiddlewareHandler[] = [], ): ToolEntry[] { const result: ToolEntry[] = [] for (const [name, entry] of commands) { if ('_alias' in entry) continue + if (entry.mcp === false) continue const path = [...prefix, name] if ('_group' in entry && entry._group) { const groupMw = [ ...parentMiddlewares, ...((entry.middlewares as MiddlewareHandler[] | undefined) ?? []), ] - result.push(...collectTools(entry.commands, path, groupMw)) + result.push(...collectToolEntries(entry.commands, path, groupMw)) } else { + const mcp = entry.mcp === false ? undefined : entry.mcp const outputSchema = entry.output ? mcpOutputSchema(entry.output) : undefined result.push({ - name: entry.mcp?.name ?? path.join('_'), - description: entry.mcp?.description ?? entry.description, + name: mcp?.name ?? path.join('_'), + description: mcp?.description ?? entry.description, inputSchema: buildToolSchema(entry.args, entry.options), ...(outputSchema ? { outputSchema } : undefined), - ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined), - ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined), + ...(mcp?.annotations ? { annotations: mcp.annotations } : undefined), + ...(mcp?.instructions ? { instructions: mcp.instructions } : undefined), command: entry, ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined), }) } } - assertUniqueToolNames(result) - return result.sort((a, b) => a.name.localeCompare(b.name)) + return result +} + +/** Filters MCP tools by include and exclude patterns. */ +export function filterTools(tools: ToolEntry[], filter?: ToolFilter | undefined): ToolEntry[] { + if (!filter) return tools + const includes = filter.include?.map(patternToRegExp) + const excludes = filter.exclude?.map(patternToRegExp) ?? [] + return tools.filter((tool) => { + if (excludes.some((pattern) => pattern.test(tool.name))) return false + if (!includes || includes.length === 0) return true + return includes.some((pattern) => pattern.test(tool.name)) + }) +} + +function patternToRegExp(pattern: string) { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '.*') + return new RegExp(`^${escaped}$`) } function assertUniqueToolNames(tools: ToolEntry[]) { diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 4f177ced..138dee86 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -107,7 +107,7 @@ export declare namespace sync { destructive?: boolean | undefined env?: any hint?: string | undefined - mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined + mcp?: false | { annotations?: Mcp.ToolAnnotations | undefined } | undefined options?: any output?: any examples?: any[] | undefined @@ -216,7 +216,7 @@ export declare namespace list { destructive?: boolean | undefined env?: any hint?: string | undefined - mcp?: { annotations?: Mcp.ToolAnnotations | undefined } | undefined + mcp?: false | { annotations?: Mcp.ToolAnnotations | undefined } | undefined options?: any output?: any examples?: any[] | undefined