diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 8409a45..ae11f8e 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -1,4 +1,4 @@ -import { Cli, Errors, z } from 'incur' +import { Cli, Errors, Mcp, z } from 'incur' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -4652,3 +4652,31 @@ describe('command aliases', () => { expect(output).toContain('updated') }) }) + +describe('--mcp', () => { + test('mcp.instructions from create() is forwarded to Mcp.serve', async () => { + const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined) + try { + const cli = Cli.create('test', { mcp: { instructions: 'Always pass --dry-run first.' } }) + cli.command('ping', { run: () => ({ pong: true }) }) + await cli.serve(['--mcp']) + expect(spy).toHaveBeenCalledOnce() + expect(spy.mock.calls[0]![3]).toMatchObject({ instructions: 'Always pass --dry-run first.' }) + } finally { + spy.mockRestore() + } + }) + + test('instructions is omitted from Mcp.serve when not set in create()', async () => { + const spy = vi.spyOn(Mcp, 'serve').mockResolvedValue(undefined) + try { + const cli = Cli.create('test') + cli.command('ping', { run: () => ({ pong: true }) }) + await cli.serve(['--mcp']) + expect(spy).toHaveBeenCalledOnce() + expect(spy.mock.calls[0]![3]?.instructions).toBeUndefined() + } finally { + spy.mockRestore() + } + }) +}) diff --git a/src/Cli.ts b/src/Cli.ts index 47bbf20..0d8b550 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -424,6 +424,23 @@ export declare namespace create { agents?: string[] | undefined /** Override the command agents will run to start the MCP server. Auto-detected if omitted. */ command?: string | undefined + /** Instructions describing how to use the server and its features. */ + instructions?: string | undefined + /** MCP tool annotations for this command. */ + annotations?: + | { + /** A human-readable title for the tool. */ + title?: string + /** If true, the tool does not modify its environment. Default: false. */ + readOnlyHint?: boolean + /** If true, the tool may perform destructive updates to its environment. Meaningful only when readOnlyHint is false. Default: true. */ + destructiveHint?: boolean + /** If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnlyHint is false. Default: false. */ + idempotentHint?: boolean + /** If true, the tool may interact with an open world of external entities. Default: true. */ + openWorldHint?: boolean + } + | undefined } | undefined /** Options for the built-in `skills add` command. */ @@ -512,6 +529,7 @@ async function serveImpl( env: options.envSchema, vars: options.vars, version: options.version, + ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined), }) return } @@ -2008,6 +2026,16 @@ declare namespace serveImpl { | { agents?: string[] | undefined command?: string | undefined + instructions?: string | undefined + annotations?: + | { + title?: string + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean + } + | undefined } | undefined /** Root command handler, invoked when no subcommand matches. */ diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index b43caf5..2eb41c2 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -410,4 +410,85 @@ describe('Mcp', () => { expect(progress[0].params.progress).toBe(1) expect(progress[1].params.progress).toBe(2) }) + + test('serve options.instructions appears in initialize response', async () => { + 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', createTestCommands(), { + input, + output, + instructions: 'Use this CLI to run test commands.', + }) + + input.write( + `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`, + ) + await new Promise((r) => setTimeout(r, 20)) + input.end() + await done + + const [res] = chunks.map((c) => JSON.parse(c.trim())) + expect(res.result.instructions).toBe('Use this CLI to run test commands.') + }) + + test('command mcp.annotations appear in tools/list', async () => { + const commands = new Map() + commands.set('read-data', { + description: 'Read some data', + mcp: { annotations: { readOnlyHint: true, idempotentHint: true } }, + run: () => ({ data: 42 }), + }) + + const [, res] = await mcpSession(commands, [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/list', params: {} }, + ]) + const tool = res.result.tools.find((t: any) => t.name === 'read-data') + expect(tool.annotations).toEqual({ readOnlyHint: true, idempotentHint: true }) + }) + + test('command mcp.instructions appear in tools/list as _meta.instructions', async () => { + const commands = new Map() + commands.set('guided', { + description: 'A guided command', + mcp: { instructions: 'Pass a valid JSON payload.' }, + run: () => ({ ok: true }), + }) + + const [, res] = await mcpSession(commands, [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/list', params: {} }, + ]) + const tool = res.result.tools.find((t: any) => t.name === 'guided') + expect(tool._meta?.instructions).toBe('Pass a valid JSON payload.') + }) + + test('collectTools extracts annotations and instructions from entry.mcp', () => { + const commands = new Map() + commands.set('destroy', { + description: 'Destructive op', + mcp: { + annotations: { destructiveHint: true, openWorldHint: false }, + instructions: 'Only call this in dry-run mode.', + }, + run: () => null, + }) + + const tools = Mcp.collectTools(commands, []) + expect(tools).toHaveLength(1) + expect(tools[0]?.annotations).toEqual({ destructiveHint: true, openWorldHint: false }) + expect(tools[0]?.instructions).toBe('Only call this in dry-run mode.') + }) + + test('collectTools omits annotations/instructions when not set', () => { + const commands = new Map() + commands.set('plain', { description: 'No mcp opts', run: () => null }) + + const tools = Mcp.collectTools(commands, []) + expect(tools[0]?.annotations).toBeUndefined() + expect(tools[0]?.instructions).toBeUndefined() + }) }) diff --git a/src/Mcp.ts b/src/Mcp.ts index c622a02..69e1f55 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -13,7 +13,10 @@ export async function serve( commands: Map, options: serve.Options = {}, ): Promise { - const server = new McpServer({ name, version }) + const server = new McpServer( + { name, version }, + options.instructions ? { instructions: options.instructions } : undefined, + ) for (const tool of collectTools(commands, [])) { const mergedShape: Record = { @@ -28,6 +31,8 @@ export async function serve( ...(tool.description ? { description: tool.description } : undefined), ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined), ...(tool.outputSchema ? { outputSchema: 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 @@ -67,6 +72,8 @@ export declare namespace serve { vars?: z.ZodObject | undefined /** CLI version string. */ version?: string | undefined + /** Instructions describing how to use the server and its features. */ + instructions?: string | undefined } } @@ -161,6 +168,16 @@ export type ToolEntry = { description?: string | undefined inputSchema: { type: 'object'; properties: Record; required?: string[] } outputSchema?: Record | undefined + annotations?: + | { + title?: string + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean + } + | undefined + instructions?: string | undefined command: any middlewares?: MiddlewareHandler[] | undefined } @@ -189,6 +206,8 @@ export function collectTools( ...(entry.output ? { outputSchema: Schema.toJsonSchema(entry.output) as Record } : undefined), + ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined), + ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined), command: entry, ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined), })