Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-tool-filtering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Added MCP tool filtering via `mcp: false` on commands and root-level `mcp.tools` include/exclude patterns.
6 changes: 6 additions & 0 deletions src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand Down
78 changes: 78 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 19 additions & 2 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<commands, vars, env, globals>
}
Expand Down Expand Up @@ -247,6 +249,7 @@ export function create(
const pending: Promise<void>[] = []
const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0', {
stateless: def.mcp?.stateless,
tools: def.mcp?.tools,
})

if (def.openapi && rootFetch) {
Expand Down Expand Up @@ -287,6 +290,7 @@ export function create(
description: def.description,
commands: generated as Map<string, CommandEntry>,
...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
...(def.mcp === false ? { mcp: false } : undefined),
} as InternalGroup
assertNoGlobalOptionConflicts(nameOrCli, entry, toGlobals.get(cli))
commands.set(nameOrCli, entry)
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<string, any> = {
...tool.command.args?.shape,
...tool.command.options?.shape,
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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<string, CommandEntry>
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion src/Mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ const initParams = {
async function mcpSession(
commands: Map<string, any>,
messages: { method: string; params?: unknown; id?: number }[],
options: Omit<Mcp.serve.Options, 'input' | 'output'> = {},
) {
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 }
Expand Down Expand Up @@ -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<string, any>([
['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<string, any>([
['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<string, any>()
commands.set('whoami', {
Expand Down
Loading
Loading