Skip to content
Open
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
30 changes: 29 additions & 1 deletion src/Cli.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
}
})
})
28 changes: 28 additions & 0 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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. */
Expand Down
81 changes: 81 additions & 0 deletions src/Mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>()
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<string, any>()
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<string, any>()
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<string, any>()
commands.set('plain', { description: 'No mcp opts', run: () => null })

const tools = Mcp.collectTools(commands, [])
expect(tools[0]?.annotations).toBeUndefined()
expect(tools[0]?.instructions).toBeUndefined()
})
})
21 changes: 20 additions & 1 deletion src/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export async function serve(
commands: Map<string, any>,
options: serve.Options = {},
): Promise<void> {
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<string, any> = {
Expand All @@ -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
Expand Down Expand Up @@ -67,6 +72,8 @@ export declare namespace serve {
vars?: z.ZodObject<any> | undefined
/** CLI version string. */
version?: string | undefined
/** Instructions describing how to use the server and its features. */
instructions?: string | undefined
}
}

Expand Down Expand Up @@ -161,6 +168,16 @@ export type ToolEntry = {
description?: string | undefined
inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
outputSchema?: Record<string, unknown> | undefined
annotations?:
| {
title?: string
readOnlyHint?: boolean
destructiveHint?: boolean
idempotentHint?: boolean
openWorldHint?: boolean
}
| undefined
instructions?: string | undefined
command: any
middlewares?: MiddlewareHandler[] | undefined
}
Expand Down Expand Up @@ -189,6 +206,8 @@ export function collectTools(
...(entry.output
? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
: 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),
})
Expand Down