diff --git a/.changeset/progressive-mcp-discovery.md b/.changeset/progressive-mcp-discovery.md new file mode 100644 index 0000000..b5c4969 --- /dev/null +++ b/.changeset/progressive-mcp-discovery.md @@ -0,0 +1,5 @@ +--- +'incur': patch +--- + +Changed MCP servers to expose progressive tool discovery by default. diff --git a/README.md b/README.md index df28490..87a1823 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,7 @@ $ my-cli docs search --query tempo # → results: ... ``` -Each MCP tool becomes a plain incur subcommand, so it is also available through `cli.fetch` and through incur's own MCP server as `_`. +Each MCP tool becomes a plain incur subcommand, so it is also available through `cli.fetch` and through incur's own MCP server as `_`. Progressive remote catalogs are resolved automatically. ### Serve CLIs as APIs @@ -424,12 +424,20 @@ Async generator commands stream as NDJSON (`application/x-ndjson`). Middleware r #### MCP over HTTP -The `fetch` handler automatically exposes an MCP endpoint at `/mcp`. Agents can discover and call your CLI's commands as MCP tools over HTTP — no stdio required: +The `fetch` handler automatically exposes an MCP endpoint at `/mcp`. Agents can discover and call your CLI's commands over HTTP, with no stdio required: ``` POST /mcp { "jsonrpc": "2.0", "method": "initialize", ... } POST /mcp { "jsonrpc": "2.0", "method": "tools/list", ... } -POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "users", ... } } +POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "search_tools", ... } } +POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "get_tool_details", ... } } +POST /mcp { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "call_read_tool", ... } } +``` + +MCP servers use progressive discovery by default: clients search a compact catalog, inspect one full schema, then execute through a read or write gate. This keeps command schemas out of `tools/list`. Set `mcp.tools.discovery` to `'direct'` for clients that require every command as a top-level tool: + +```ts +Cli.create('my-cli', { mcp: { tools: { discovery: 'direct' } } }) ``` Non-`/mcp` paths continue routing to the command API as usual. @@ -457,7 +465,7 @@ Most CLIs expose tools via MCP or a single monolithic skill file. incur combines The table below models a session with a 20-command CLI producing full output envelopes. -- **Session start** – tokens consumed just by having the tool available. _MCP injects all tool schemas into every turn; skills only load frontmatter (name + description)._ +- **Session start** – tokens consumed just by having the tool available. _Traditional MCP servers inject all tool schemas into every turn; skills only load frontmatter (name + description)._ - **Discovery** – tokens to learn what commands exist and how to call them. _MCP gets this at session start; skills load the full skill file on demand; incur splits by command group so only relevant commands are loaded._ - **Invocation (×5)** – tokens per tool call. - **Response (×5)** – tokens in CLI output. _MCP and skills return JSON; incur defaults to TOON which strips braces, quotes, and keys._ diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 1de63ef..d4edbc7 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -400,11 +400,22 @@ test('command mcp metadata accepts instructions and annotations', () => { mcp: { instructions: 'Use this server for test commands.', stateless: false, - tools: { include: ['read_*'], exclude: ['*_secret'] }, + tools: { + discovery: 'progressive', + include: ['read_*'], + exclude: ['*_secret'], + }, // @ts-expect-error -- annotations belong on command definitions annotations: { readOnlyHint: true }, }, }) + + Cli.create('test', { + mcp: { + // @ts-expect-error -- discovery only accepts supported strategies + tools: { discovery: 'lazy' }, + }, + }) }) test('command metadata accepts destructive flag', () => { diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 7201b57..3e80012 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -2967,6 +2967,31 @@ describe('built-in commands', () => { }) }) + test('mcp doctor waits for delayed tool listings', async () => { + const spy = vi + .spyOn(Mcp, 'serve') + .mockImplementation(async (_name, _version, _commands, options) => { + await new Promise((resolve) => setTimeout(resolve, 50)) + options!.output?.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 1, result: { serverInfo: {} } })}\n`, + ) + options!.output?.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'ping' }] } })}\n`, + ) + }) + try { + const cli = Cli.create('test') + cli.command('ping', { run: () => ({ pong: true }) }) + + const { output, exitCode } = await serve(cli, ['mcp', 'doctor', '--json']) + + expect(exitCode).toBeUndefined() + expect(JSON.parse(output)).toMatchObject({ ok: true, toolCount: 1 }) + } finally { + spy.mockRestore() + } + }) + test('mcp doctor applies root MCP tool filters', async () => { const cli = Cli.create('test', { version: '1.0.0', @@ -5647,7 +5672,10 @@ describe('fetch', () => { describe('mcp over http', () => { function mcpCli() { - const cli = Cli.create('test', { version: '1.0.0' }) + const cli = Cli.create('test', { + version: '1.0.0', + mcp: { tools: { discovery: 'direct' } }, + }) cli.command('greet', { description: 'Greet someone', args: z.object({ name: z.string() }), @@ -5792,7 +5820,10 @@ 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' }) + const cli = Cli.create('test', { + version: '1.0.0', + mcp: { tools: { discovery: 'direct' } }, + }) cli.command('public', { run: () => ({ public: true }) }) cli.command('secret', { mcp: false, run: () => ({ secret: true }) }) @@ -5826,7 +5857,13 @@ describe('fetch', () => { 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'] } }, + mcp: { + tools: { + discovery: 'direct', + include: ['docs_*'], + exclude: ['*_secret'], + }, + }, }) cli.command('docs_list', { run: () => null }) cli.command('docs_secret', { run: () => null }) @@ -5907,7 +5944,10 @@ describe('fetch', () => { }) test('POST /mcp exposes per-request headers to command context', async () => { - const cli = Cli.create('test', { version: '1.0.0' }) + const cli = Cli.create('test', { + version: '1.0.0', + mcp: { tools: { discovery: 'direct' } }, + }) cli.command('auth', { description: 'Auth', run: (c) => ({ authorization: c.request?.headers.get('authorization') }), diff --git a/src/Cli.ts b/src/Cli.ts index 9e7558e..b12f7d5 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -594,7 +594,7 @@ 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. */ + /** Controls how command tools are exposed to MCP clients. */ tools?: Mcp.ToolFilter | undefined } | undefined @@ -1842,37 +1842,17 @@ function createMcpHttpHandler( await import('@modelcontextprotocol/server') const server = new McpServer({ name, version }) - - for (const tool of Mcp.collectTools(commands, [], [], options.tools)) { - const mergedShape: Record = { - ...tool.command.args?.shape, - ...tool.command.options?.shape, - } - const hasInput = Object.keys(mergedShape).length > 0 - - server.registerTool( - tool.name, - { - ...(tool.description ? { description: tool.description } : undefined), - ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined), - ...(tool.outputSchema - ? { outputSchema: fromJsonSchema(tool.outputSchema) } - : undefined), - }, - async (...callArgs: any[]) => { - const params = hasInput ? (callArgs[0] as Record) : {} - const extra = hasInput ? callArgs[1] : callArgs[0] - return Mcp.callTool(tool, params, { - name, - version, - middlewares: mcpOptions?.middlewares, - env: mcpOptions?.env, - vars: mcpOptions?.vars, - request: extra?.http?.req, - }) - }, - ) - } + Mcp.registerTools(server, commands, { + env: mcpOptions?.env, + fromJsonSchema, + middlewares: mcpOptions?.middlewares, + name, + request: (extra) => extra?.http?.req, + sendNotification: (notification) => server.server.notification(notification), + tools: options.tools, + vars: mcpOptions?.vars, + version, + }) const transportOptions = stateless ? { enableJsonResponse: true } @@ -2794,10 +2774,14 @@ 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), + tools: { ...options.mcp?.tools, discovery: 'direct' }, }).catch((error) => { serveError = error }) + let serveFinished = false + void done.finally(() => { + serveFinished = true + }) input.write( `${Json.stringify({ @@ -2812,7 +2796,7 @@ async function runMcpDoctor( })}\n`, ) input.write(`${Json.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`) - await new Promise((resolve) => setTimeout(resolve, 20)) + await waitForMcpDoctorResponses(chunks, () => serveFinished) input.end() await done @@ -2875,6 +2859,14 @@ async function runMcpDoctor( } } +async function waitForMcpDoctorResponses(chunks: string[], finished: () => boolean) { + const started = Date.now() + while (!finished() && (chunks.join('').match(/\n/g)?.length ?? 0) < 2) { + if (Date.now() - started >= 1_000) return + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + function parseMcpDoctorResponses(chunks: string[]): Record[] { const responses: Record[] = [] for (const line of chunks.join('').split(/\r?\n/)) { diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index ac51ced..d81d6b9 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -78,7 +78,13 @@ async function mcpSession( const chunks: string[] = [] output.on('data', (chunk) => chunks.push(chunk.toString())) - const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output, ...options }) + const { tools, ...rest } = options + const done = Mcp.serve('test-cli', '1.0.0', commands, { + input, + output, + ...rest, + tools: { discovery: 'direct', ...tools }, + }) for (const msg of messages) { const rpc = { jsonrpc: '2.0', ...msg } @@ -142,6 +148,139 @@ describe('Mcp', () => { expect(echoTool.inputSchema.required).toContain('message') }) + test('tools/list defaults to progressive discovery', async () => { + const [, res] = await mcpSession( + createTestCommands(), + [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/list', params: {} }, + ], + { tools: { discovery: undefined } }, + ) + + expect(res.result.tools.map((tool: any) => tool.name)).toEqual([ + 'search_tools', + 'get_tool_details', + 'call_read_tool', + 'call_write_tool', + ]) + expect(res.result.tools.some((tool: any) => tool.name === 'echo')).toBe(false) + }) + + test('progressive discovery searches, inspects, and gates execution', async () => { + const commands = new Map([ + [ + 'read-user', + { + description: 'Read a user profile', + args: z.object({ id: z.string() }), + mcp: { annotations: { readOnlyHint: true } }, + run: (c: any) => ({ id: c.args.id }), + }, + ], + [ + 'delete-user', + { + description: 'Delete a user profile', + args: z.object({ id: z.string() }), + mcp: { annotations: { destructiveHint: true, readOnlyHint: false } }, + run: (c: any) => ({ deleted: c.args.id }), + }, + ], + ]) + const responses = await mcpSession( + commands, + [ + { id: 1, method: 'initialize', params: initParams }, + { + id: 2, + method: 'tools/call', + params: { name: 'search_tools', arguments: { query: 'user', limit: 1 } }, + }, + { + id: 3, + method: 'tools/call', + params: { name: 'get_tool_details', arguments: { name: 'read-user' } }, + }, + { + id: 4, + method: 'tools/call', + params: { + name: 'call_read_tool', + arguments: { name: 'read-user', arguments: { id: '1' } }, + }, + }, + { + id: 5, + method: 'tools/call', + params: { + name: 'call_read_tool', + arguments: { name: 'delete-user', arguments: { id: '1' } }, + }, + }, + { + id: 6, + method: 'tools/call', + params: { + name: 'call_write_tool', + arguments: { name: 'delete-user', arguments: { id: '1' } }, + }, + }, + { + id: 7, + method: 'tools/call', + params: { + name: 'call_write_tool', + arguments: { name: 'read-user', arguments: { id: '1' } }, + }, + }, + ], + { tools: { discovery: 'progressive' } }, + ) + + const byId = new Map(responses.map((response) => [response.id, response])) + const search = JSON.parse(byId.get(2).result.content[0].text) + expect(search.tools).toHaveLength(1) + expect(search.tools[0]).toMatchObject({ name: 'delete-user' }) + expect(search.tools[0]).not.toHaveProperty('inputSchema') + + const details = JSON.parse(byId.get(3).result.content[0].text) + expect(details.name).toBe('read-user') + expect(details.inputSchema.properties.id).toBeDefined() + expect(JSON.parse(byId.get(4).result.content[0].text)).toEqual({ id: '1' }) + expect(byId.get(5).result).toMatchObject({ isError: true }) + expect(JSON.parse(byId.get(5).result.content[0].text)).toEqual({ + error: 'Tool is not read-only: delete-user', + }) + expect(JSON.parse(byId.get(6).result.content[0].text)).toEqual({ deleted: '1' }) + expect(byId.get(7).result).toMatchObject({ isError: true }) + expect(JSON.parse(byId.get(7).result.content[0].text)).toEqual({ + error: 'Tool is read-only: read-user', + }) + }) + + test('progressive discovery applies tool filters before search', async () => { + const [, res] = await mcpSession( + new Map([ + ['docs-list', { description: 'List docs', run: () => null }], + ['secret-list', { description: 'List secrets', run: () => null }], + ]), + [ + { id: 1, method: 'initialize', params: initParams }, + { + id: 2, + method: 'tools/call', + params: { name: 'search_tools', arguments: { query: 'list' } }, + }, + ], + { tools: { discovery: 'progressive', exclude: ['secret-*'] } }, + ) + + expect(JSON.parse(res.result.content[0].text).tools.map((tool: any) => tool.name)).toEqual([ + 'docs-list', + ]) + }) + test('collectTools hides commands and groups with mcp false', () => { const commands = createTestCommands() commands.set('secret', { mcp: false, run: () => ({ ok: true }) }) @@ -528,6 +667,7 @@ describe('Mcp', () => { input, output, middlewares, + tools: { discovery: 'direct' }, vars: z.object({ ran: z.boolean().default(false) }), }) @@ -567,6 +707,7 @@ describe('Mcp', () => { input, output, middlewares, + tools: { discovery: 'direct' }, }) input.write( @@ -615,6 +756,7 @@ describe('Mcp', () => { const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output, + tools: { discovery: 'direct' }, vars: z.object({ group: z.string().default('none') }), }) @@ -656,7 +798,11 @@ describe('Mcp', () => { const chunks: any[] = [] output.on('data', (chunk) => chunks.push(JSON.parse(chunk.toString().trim()))) - const done = Mcp.serve('test-cli', '1.0.0', createTestCommands(), { input, output }) + const done = Mcp.serve('test-cli', '1.0.0', createTestCommands(), { + input, + output, + tools: { discovery: 'direct' }, + }) // Initialize input.write( diff --git a/src/Mcp.ts b/src/Mcp.ts index 3145fdf..875024a 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -1,4 +1,4 @@ -import type { Transport } from '@modelcontextprotocol/server' +import type { McpServer, Transport } from '@modelcontextprotocol/server' import type { Readable, Writable } from 'node:stream' import { z } from 'zod' @@ -26,38 +26,16 @@ export async function serve( options.instructions ? { instructions: options.instructions } : undefined, ) - for (const tool of collectTools(commands, [], [], options.tools)) { - const mergedShape: Record = { - ...tool.command.args?.shape, - ...tool.command.options?.shape, - } - const hasInput = Object.keys(mergedShape).length > 0 - - server.registerTool( - tool.name, - { - ...(tool.description ? { description: tool.description } : undefined), - ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined), - ...(tool.outputSchema ? { outputSchema: fromJsonSchema(tool.outputSchema) } : undefined), - ...(tool.annotations ? { annotations: tool.annotations } : undefined), - ...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined), - } as never, - async (...callArgs: any[]) => { - // registerTool passes (args, extra) when inputSchema is set, (extra) when not - const params = hasInput ? (callArgs[0] as Record) : {} - const extra = hasInput ? callArgs[1] : callArgs[0] - return callTool(tool, params, { - extra, - sendNotification: (n) => server.server.notification(n), - name, - version, - middlewares: options.middlewares, - env: options.env, - vars: options.vars, - }) - }, - ) - } + registerTools(server, commands, { + env: options.env, + fromJsonSchema, + middlewares: options.middlewares, + name, + sendNotification: (notification) => server.server.notification(notification), + tools: options.tools, + vars: options.vars, + version, + }) const input = options.input ?? process.stdin const output = options.output ?? process.stdout @@ -246,14 +224,260 @@ export type ToolAnnotations = { openWorldHint?: boolean | undefined } -/** MCP tool name filtering options. */ +/** MCP tool exposure options. */ export type ToolFilter = { + /** Tool discovery strategy. Progressive discovery exposes search, inspect, and execution tools instead of every command schema. Defaults to `'progressive'`. */ + discovery?: 'direct' | 'progressive' | undefined /** 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 Registers direct or progressively discovered MCP tools. */ +export function registerTools( + server: McpServer, + commands: Map, + options: registerTools.Options, +) { + const tools = collectTools(commands, [], [], options.tools) + if (tools.length === 0) return + if ((options.tools?.discovery ?? 'progressive') === 'direct') { + for (const tool of tools) registerDirectTool(server, tool, options) + return + } + registerDiscoveryTools(server, tools, options) +} + +export declare namespace registerTools { + /** Options shared by stdio and HTTP MCP tool registration. */ + type Options = { + /** CLI-level env schema. */ + env?: z.ZodObject | undefined + /** Converts JSON Schema output definitions for the MCP SDK. */ + fromJsonSchema: typeof import('@modelcontextprotocol/server').fromJsonSchema + /** Middleware handlers registered on the root CLI. */ + middlewares?: MiddlewareHandler[] | undefined + /** MCP server name. */ + name: string + /** Resolves the inbound HTTP request from MCP call metadata. */ + request?: ((extra: any) => Request | undefined) | undefined + /** Sends MCP progress notifications. */ + sendNotification?: ((notification: ProgressNotification) => Promise) | undefined + /** Tool exposure options. */ + tools?: ToolFilter | undefined + /** Vars schema for middleware variables. */ + vars?: z.ZodObject | undefined + /** MCP server version. */ + version: string + } +} + +function registerDirectTool( + server: Parameters[0], + tool: ToolEntry, + options: registerTools.Options, +) { + const mergedShape: Record = { + ...tool.command.args?.shape, + ...tool.command.options?.shape, + } + const hasInput = Object.keys(mergedShape).length > 0 + + server.registerTool( + tool.name, + { + ...(tool.description ? { description: tool.description } : undefined), + ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined), + ...(tool.outputSchema + ? { outputSchema: options.fromJsonSchema(tool.outputSchema) } + : undefined), + ...(tool.annotations ? { annotations: tool.annotations } : undefined), + ...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined), + }, + async (...callArgs: any[]) => { + // registerTool passes (args, extra) when inputSchema is set, (extra) when not. + const params = hasInput ? (callArgs[0] as Record) : {} + const extra = hasInput ? callArgs[1] : callArgs[0] + return callTool(tool, params, callOptions(options, extra)) + }, + ) +} + +function registerDiscoveryTools( + server: Parameters[0], + tools: ToolEntry[], + options: registerTools.Options, +) { + const byName = new Map(tools.map((tool) => [tool.name, tool])) + + server.registerTool( + 'search_tools', + { + description: + 'Search or page through available tools by capability. Returns names and descriptions without loading their schemas. Inspect a result before calling it.', + inputSchema: z.object({ + limit: z.number().int().min(1).max(20).default(5).describe('Maximum matches.'), + offset: z.number().int().min(0).default(0).describe('Matches to skip.'), + query: z.string().default('').describe('Capability to find. Empty lists all tools.'), + }), + annotations: catalogAnnotations, + }, + async (params: { limit: number; offset: number; query: string }) => { + const matches = searchTools(tools, params.query) + const page = matches.slice(params.offset, params.offset + params.limit) + return toolResult({ + tools: page.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : undefined), + ...(tool.annotations ? { annotations: tool.annotations } : undefined), + })), + ...(params.offset + page.length < matches.length + ? { nextOffset: params.offset + page.length } + : undefined), + }) + }, + ) + + server.registerTool( + 'get_tool_details', + { + description: + 'Inspect one tool returned by search_tools. Returns its complete input schema and metadata.', + inputSchema: z.object({ name: z.string().min(1).describe('Exact tool name.') }), + annotations: catalogAnnotations, + }, + async (params: { name: string }) => { + const tool = byName.get(params.name) + if (!tool) return toolError(`Unknown tool: ${params.name}`) + return toolResult({ + name: tool.name, + ...(tool.description ? { description: tool.description } : undefined), + inputSchema: tool.inputSchema, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined), + ...(tool.annotations ? { annotations: tool.annotations } : undefined), + ...(tool.instructions ? { instructions: tool.instructions } : undefined), + }) + }, + ) + + server.registerTool( + 'call_read_tool', + { + description: + 'Execute a tool marked read-only after inspecting its schema with get_tool_details.', + inputSchema: callSchema, + annotations: readAnnotations, + }, + async (params: CallParams, extra: any) => { + const tool = byName.get(params.name) + if (!tool) return toolError(`Unknown tool: ${params.name}`) + if (tool.annotations?.readOnlyHint !== true) + return toolError(`Tool is not read-only: ${params.name}`) + return callTool(tool, params.arguments, callOptions(options, extra)) + }, + ) + + server.registerTool( + 'call_write_tool', + { + description: + 'Execute a writable or unclassified tool after inspecting its schema with get_tool_details.', + inputSchema: callSchema, + annotations: writeAnnotations, + }, + async (params: CallParams, extra: any) => { + const tool = byName.get(params.name) + if (!tool) return toolError(`Unknown tool: ${params.name}`) + if (tool.annotations?.readOnlyHint === true) + return toolError(`Tool is read-only: ${params.name}`) + return callTool(tool, params.arguments, callOptions(options, extra)) + }, + ) +} + +type CallParams = { + name: string + arguments: Record +} + +const callSchema = z.object({ + name: z.string().min(1).describe('Exact tool name.'), + arguments: z.record(z.string(), z.unknown()).default({}).describe('Arguments from its schema.'), +}) + +const catalogAnnotations = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +} + +const readAnnotations = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, +} + +const writeAnnotations = { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, +} + +function callOptions(options: registerTools.Options, extra: any) { + return { + env: options.env, + extra, + middlewares: options.middlewares, + name: options.name, + request: options.request?.(extra), + ...(options.sendNotification ? { sendNotification: options.sendNotification } : undefined), + vars: options.vars, + version: options.version, + } +} + +function searchTools(tools: ToolEntry[], query: string) { + const normalized = normalizeSearch(query) + const terms = normalized.split(' ').filter(Boolean) + return tools + .map((tool) => ({ tool, score: toolScore(tool, normalized, terms) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name)) + .map(({ tool }) => tool) +} + +function toolScore(tool: ToolEntry, query: string, terms: string[]) { + const name = normalizeSearch(tool.name) + const description = normalizeSearch(tool.description ?? '') + if (name === query) return 1_000 + let score = name.startsWith(query) ? 100 : name.includes(query) ? 50 : 0 + for (const term of terms) { + if (name.split(' ').includes(term)) score += 20 + else if (name.includes(term)) score += 10 + if (description.includes(term)) score += 2 + } + return score +} + +function normalizeSearch(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function toolResult(value: unknown) { + return { content: [{ type: 'text' as const, text: Json.stringify(value) }] } +} + +function toolError(message: string) { + return { ...toolResult({ error: message }), isError: true } +} + /** @internal Recursively collects leaf commands as tool entries. */ export function collectTools( commands: Map, diff --git a/src/McpSource.test.ts b/src/McpSource.test.ts index ce57e11..a9dda45 100644 --- a/src/McpSource.test.ts +++ b/src/McpSource.test.ts @@ -39,7 +39,7 @@ function createRemoteCli() { } function mountRemote(remote = createRemoteCli()) { - return Cli.create('local').command('docs', { + return Cli.create('local', { mcp: { tools: { discovery: 'direct' } } }).command('docs', { description: 'Docs tools', mcp: { url: new URL('http://mcp.local/mcp'), fetch: (request) => remote.fetch(request) }, }) @@ -110,6 +110,20 @@ describe('remote MCP command sources', () => { `) }) + test('pages through progressive remote tool catalogs', async () => { + const remote = Cli.create('remote') + for (let index = 0; index < 21; index++) + remote.command(`tool-${index}`, { run: () => ({ index }) }) + const cli = mountRemote(remote) + + const result = await serve(cli, ['docs', 'tool-20', '--json']) + + expect({ exitCode: result.exitCode, body: json(result.output) }).toEqual({ + body: { index: 20 }, + exitCode: undefined, + }) + }) + test('propagates remote MCP tool errors', async () => { const cli = mountRemote() diff --git a/src/McpSource.ts b/src/McpSource.ts index f00dc66..6506990 100644 --- a/src/McpSource.ts +++ b/src/McpSource.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import type * as Fetch from './Fetch.js' +import type * as Mcp from './Mcp.js' import * as Openapi from './Openapi.js' const protocolVersion = '2025-06-18' @@ -20,6 +21,8 @@ export type Source = /** A resolved remote MCP server. */ export type Resolved = { + /** Tool discovery strategy exposed by the remote server. */ + discovery?: 'direct' | 'progressive' | undefined /** Tools returned by `tools/list`. */ tools: Tool[] /** Session state reused for later MCP calls. */ @@ -50,6 +53,8 @@ export type Tool = { inputSchema?: Record | undefined /** JSON Schema for tool output. */ outputSchema?: Record | undefined + /** Behavioral hints advertised by the remote tool. */ + annotations?: Mcp.ToolAnnotations | undefined } type Message = { id?: number | string; result?: any; error?: { message?: string; code?: unknown } } @@ -66,7 +71,10 @@ export async function resolve(source: Source, options: resolve.Options = {}): Pr session.initialized = true await request(session, 'notifications/initialized') const result = await request(session, 'tools/list') - return { tools: (result.tools ?? []) as Tool[], session } + const listed = (result.tools ?? []) as Tool[] + if (isProgressiveCatalog(listed)) + return { discovery: 'progressive', tools: await discoverTools(session), session } + return { tools: listed, session } } /** Options for resolving a remote MCP server. */ @@ -87,13 +95,22 @@ export function generateCommands(resolved: Resolved): Map | undefined /** Output schema generated from the MCP tool output schema. */ output?: z.ZodType | undefined + /** MCP annotations preserved from the remote tool. */ + mcp?: { annotations: Mcp.ToolAnnotations } | undefined /** Proxies a command invocation to `tools/call`. */ run(context: any): Promise } +function isProgressiveCatalog(tools: Tool[]) { + if (tools.length !== 4) return false + const names = new Set(tools.map((tool) => tool.name)) + return ( + names.has('search_tools') && + names.has('get_tool_details') && + names.has('call_read_tool') && + names.has('call_write_tool') + ) +} + +async function discoverTools(session: Session) { + const tools: Tool[] = [] + let offset: number | undefined = 0 + while (offset !== undefined) { + const search = (await callRemoteTool(session, 'search_tools', { + query: '', + limit: 20, + offset, + })) as { tools?: { name?: string }[]; nextOffset?: number } + for (const match of search.tools ?? []) { + if (!match.name) continue + const tool = (await callRemoteTool(session, 'get_tool_details', { + name: match.name, + })) as Tool + if (tool.name) tools.push(tool) + } + if (search.nextOffset !== undefined && search.nextOffset <= offset) + throw new Error('MCP tool catalog returned a non-advancing offset') + offset = search.nextOffset + } + return tools +} + +async function callRemoteTool( + session: Session, + name: string, + arguments_: Record, +) { + const result = await request(session, 'tools/call', { name, arguments: arguments_ }) + if (result.isError) throw new Error(resultText(result) || `MCP tool failed: ${name}`) + return resultValue(result) +} + function sourceSession(source: Source): Session { if (typeof source === 'string' || source instanceof URL) return { url: new URL(source), fetch: globalThis.fetch.bind(globalThis) } diff --git a/src/Openapi.test.ts b/src/Openapi.test.ts index bdb8352..60163f8 100644 --- a/src/Openapi.test.ts +++ b/src/Openapi.test.ts @@ -161,10 +161,22 @@ describe('generateCommands', () => { expect(cmd.mcp?.description).toBe( 'List users\n\nReturns users ordered by creation date. Use `limit` to cap the page size.', ) - // Summary-only operations get no MCP override. const summaryOnly = commands.get('createUser')! if ('_group' in summaryOnly) throw new Error('expected createUser command') - expect(summaryOnly.mcp).toBeUndefined() + expect(summaryOnly.mcp).toEqual({ + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + }) + expect(cmd.mcp?.annotations).toEqual({ + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + }) }) test('coerced number params preserve description', async () => { @@ -537,7 +549,10 @@ describe('cli integration', () => { jsonrpc: '2.0', id: 1, method: 'tools/call', - params: { name: 'api_getSecret', arguments: {} }, + params: { + name: 'call_read_tool', + arguments: { name: 'api_getSecret', arguments: {} }, + }, }), }), ) diff --git a/src/Openapi.ts b/src/Openapi.ts index 0f8b912..aa5180c 100644 --- a/src/Openapi.ts +++ b/src/Openapi.ts @@ -9,6 +9,7 @@ import { z } from 'zod' import * as Cli from './Cli.js' import * as Fetch from './Fetch.js' import { dereference } from './internal/dereference.js' +import type * as Mcp from './Mcp.js' import * as Schema from './Schema.js' /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */ @@ -126,7 +127,12 @@ type FetchHandler = (req: Request) => Response | Promise type GeneratedCommand = { args?: z.ZodObject | undefined description?: string | undefined - mcp?: { description: string } | undefined + mcp?: + | { + annotations: Mcp.ToolAnnotations + description?: string | undefined + } + | undefined options?: z.ZodObject | undefined run: (context: any) => any } @@ -440,10 +446,13 @@ export async function generateCommands( setCommand(commands, segments, { description: op.summary ?? op.description, - // Help keeps the short summary; MCP tools surface the full prose. - ...(op.summary && op.description && op.summary !== op.description - ? { mcp: { description: `${op.summary}\n\n${op.description}` } } - : undefined), + mcp: { + annotations: mcpAnnotations(method), + // Help keeps the short summary; MCP tools surface the full prose. + ...(op.summary && op.description && op.summary !== op.description + ? { description: `${op.summary}\n\n${op.description}` } + : undefined), + }, args: argsSchema, options: optionsSchema, run: createHandler({ @@ -463,6 +472,16 @@ export async function generateCommands( return commands } +function mcpAnnotations(method: string) { + const readOnly = ['get', 'head', 'options', 'trace'].includes(method) + return { + destructiveHint: !readOnly, + idempotentHint: readOnly || method === 'delete' || method === 'put', + openWorldHint: true, + readOnlyHint: readOnly, + } +} + export declare namespace generateCommands { /** Options for generating incur commands from an OpenAPI spec. */ type Options = { diff --git a/src/e2e.test.ts b/src/e2e.test.ts index a14fc12..7534986 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -3715,6 +3715,7 @@ function createApp() { const cli = Cli.create('app', { version: '3.5.0', description: 'A comprehensive CLI application for testing.', + mcp: { tools: { discovery: 'direct' } }, }) cli.command('ping', {