diff --git a/.changeset/calm-dodos-smile.md b/.changeset/calm-dodos-smile.md new file mode 100644 index 0000000..0a0e2a6 --- /dev/null +++ b/.changeset/calm-dodos-smile.md @@ -0,0 +1,9 @@ +--- +'incur': patch +--- + +Added configurable MCP server names and titles. + +```ts +Cli.create('tapimo', { mcp: { name: 'tempo', title: 'Tempo MCP' } }) +``` diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 59c0517..c34b7e4 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -2944,6 +2944,29 @@ describe('built-in commands', () => { } }) + test('mcp add registers an overridden MCP name with the CLI command', async () => { + const spy = vi + .spyOn(SyncMcp, 'register') + .mockResolvedValue({ command: 'pnpm test --mcp', agents: ['Cursor'] }) + try { + const cli = Cli.create('test', { mcp: { name: 'example' } }) + cli.command('ping', { run: () => ({ pong: true }) }) + const { output, exitCode } = await serve(cli, ['mcp', 'add']) + + expect(exitCode).toBeUndefined() + expect(spy).toHaveBeenCalledWith('example', { + agents: [], + cli: 'test', + command: undefined, + global: true, + }) + expect(output).toContain('Registered example as MCP server') + expect(output).toContain('Agents can now use example tools.') + } finally { + spy.mockRestore() + } + }) + test('mcp add exits nonzero when registration fails', async () => { const spy = vi.spyOn(SyncMcp, 'register').mockRejectedValue('register failed') try { @@ -5691,7 +5714,12 @@ describe('fetch', () => { function mcpCli() { const cli = Cli.create('test', { version: '1.0.0', - mcp: { tools: { discovery: 'direct' } }, + mcp: { + instructions: 'Use the example MCP.', + name: 'example', + title: 'Example MCP', + tools: { discovery: 'direct' }, + }, }) cli.command('greet', { description: 'Greet someone', @@ -5766,13 +5794,16 @@ describe('fetch', () => { expect(res.headers.get('mcp-session-id')).toBeNull() const body = await res.json() expect({ + instructions: body.result.instructions, serverInfo: body.result.serverInfo, hasTools: 'tools' in (body.result.capabilities ?? {}), }).toMatchInlineSnapshot(` { "hasTools": true, + "instructions": "Use the example MCP.", "serverInfo": { - "name": "test", + "name": "example", + "title": "Example MCP", "version": "1.0.0", }, } diff --git a/src/Cli.ts b/src/Cli.ts index 78b8a83..2a16dd2 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -257,8 +257,10 @@ export function create( const commands = new Map() const middlewares: MiddlewareHandler[] = [] const pending: Promise[] = [] - const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0', { + const mcpHandler = createMcpHttpHandler(def.mcp?.name ?? name, def.version ?? '0.0.0', { + instructions: def.mcp?.instructions, stateless: def.mcp?.stateless, + title: def.mcp?.title, tools: def.mcp?.tools, }) @@ -594,8 +596,12 @@ export declare namespace create { command?: string | undefined /** Instructions describing how to use the server and its features. */ instructions?: string | undefined + /** MCP server and registration name. Defaults to the CLI name. */ + name?: string | undefined /** Disable HTTP MCP session management. Defaults to `true`. */ stateless?: boolean | undefined + /** Human-readable MCP server title. */ + title?: string | undefined /** Controls how command tools are exposed to MCP clients. */ tools?: Mcp.ToolFilter | undefined } @@ -723,12 +729,13 @@ async function serveImpl( // --mcp: start as MCP stdio server if (mcpFlag) { - await Mcp.serve(name, options.version ?? '0.0.0', commands, { + await Mcp.serve(options.mcp?.name ?? name, options.version ?? '0.0.0', commands, { middlewares: options.middlewares, env: options.envSchema, vars: options.vars, version: options.version, ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined), + ...(options.mcp?.title ? { title: options.mcp.title } : undefined), ...(options.mcp?.tools ? { tools: options.mcp.tools } : undefined), }) return @@ -1096,18 +1103,20 @@ async function serveImpl( } try { + const mcpName = options.mcp?.name ?? name stdout('Registering MCP server...') - const result = await SyncMcp.register(name, { + const result = await SyncMcp.register(mcpName, { + ...(mcpName === name ? undefined : { cli: name }), command, global, agents, }) stdout('\r\x1b[K') const lines: string[] = [] - lines.push(`✓ Registered ${name} as MCP server`) + lines.push(`✓ Registered ${mcpName} as MCP server`) if (result.agents.length > 0) lines.push(` Agents: ${result.agents.join(', ')}`) lines.push('') - lines.push(`Agents can now use ${name} tools.`) + lines.push(`Agents can now use ${mcpName} tools.`) const suggestions = options.sync?.suggestions if (suggestions && suggestions.length > 0) { lines.push('') @@ -1118,7 +1127,7 @@ async function serveImpl( if (fullOutput || formatExplicit) writeln( Formatter.format( - { name, command: result.command, agents: result.agents }, + { name: mcpName, command: result.command, agents: result.agents }, formatExplicit ? formatFlag : 'toon', ), ) @@ -1840,7 +1849,10 @@ function createMcpHttpHandler( const { fromJsonSchema, McpServer, WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/server') - const server = new McpServer({ name, version }) + const server = new McpServer( + { name, ...(options.title ? { title: options.title } : undefined), version }, + options.instructions ? { instructions: options.instructions } : undefined, + ) Mcp.registerTools(server, commands, { env: mcpOptions?.env, fromJsonSchema, @@ -1920,8 +1932,12 @@ function createMcpHttpHandler( declare namespace createMcpHttpHandler { type Options = { + /** Instructions describing how to use the server and its features. */ + instructions?: string | undefined /** Disable HTTP MCP session management. Defaults to `true`. */ stateless?: boolean | undefined + /** Human-readable MCP server title. */ + title?: string | undefined /** Filters which command tools are exposed to MCP clients. */ tools?: Mcp.ToolFilter | undefined } @@ -2480,7 +2496,9 @@ declare namespace serveImpl { agents?: string[] | undefined command?: string | undefined instructions?: string | undefined + name?: string | undefined stateless?: boolean | undefined + title?: string | undefined tools?: Mcp.ToolFilter | undefined } | undefined @@ -2817,7 +2835,7 @@ async function runMcpDoctor( output.on('data', (chunk) => chunks.push(chunk.toString())) let serveError: unknown - const done = Mcp.serve(name, options.version ?? '0.0.0', commands, { + const done = Mcp.serve(options.mcp?.name ?? name, options.version ?? '0.0.0', commands, { input, output, middlewares: options.middlewares, @@ -2825,6 +2843,7 @@ async function runMcpDoctor( vars: options.vars, version: options.version, ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined), + ...(options.mcp?.title ? { title: options.mcp.title } : undefined), tools: { ...options.mcp?.tools, discovery: 'direct' }, }).catch((error) => { serveError = error diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index ed80efa..df125b7 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -117,6 +117,33 @@ describe('Mcp', () => { expect(res.result.capabilities.tools).toBeDefined() }) + test('initialize includes the server title', 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, + title: 'Test MCP', + }) + + 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((chunk) => JSON.parse(chunk.trim())) + expect(res.result.serverInfo).toEqual({ + name: 'test-cli', + title: 'Test MCP', + version: '1.0.0', + }) + }) + test('initialize with 2025-03-26 protocol version', async () => { const [res] = await mcpSession(createTestCommands(), [ { diff --git a/src/Mcp.ts b/src/Mcp.ts index 95a5e76..21688d7 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -22,7 +22,7 @@ export async function serve( const StdioServerTransport = await importStdioServerTransport(mcp, stdio) const server = new McpServer( - { name, version }, + { name, ...(options.title ? { title: options.title } : undefined), version }, options.instructions ? { instructions: options.instructions } : undefined, ) @@ -94,6 +94,8 @@ export declare namespace serve { version?: string | undefined /** Instructions describing how to use the server and its features. */ instructions?: string | undefined + /** Human-readable MCP server title. */ + title?: string | undefined /** Filters which command tools are exposed to MCP clients. */ tools?: ToolFilter | undefined } diff --git a/src/SyncMcp.test.ts b/src/SyncMcp.test.ts index 44aae1b..fcfd074 100644 --- a/src/SyncMcp.test.ts +++ b/src/SyncMcp.test.ts @@ -172,6 +172,22 @@ test('register uses runner for source entrypoints outside node_modules', async ( expect(result.command).toContain('my-cli --mcp') }) +test('register derives the command from a different CLI name', async () => { + process.argv[1] = join(tmp, 'dist', 'bin.js') + + const result = await register('example', { agents: ['amp'], cli: 'my-cli' }) + + expect(result.command).toMatch(/^(npx|pnpx|bunx)\s/) + expect(result.command).toContain('my-cli --mcp') + + const configPath = join(fakeHome!, '.config', 'amp', 'settings.json') + const config = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(config['amp.mcpServers']['example']).toEqual({ + command: result.command.split(' ')[0], + args: ['my-cli', '--mcp'], + }) +}) + test('register uses bare name for global package entrypoints under node_modules', async () => { process.argv[1] = join(tmp, 'global', 'node_modules', 'my-cli', 'dist', 'bin.js') diff --git a/src/SyncMcp.ts b/src/SyncMcp.ts index f9bd429..6b0e546 100644 --- a/src/SyncMcp.ts +++ b/src/SyncMcp.ts @@ -11,7 +11,7 @@ export async function register( options: register.Options = {}, ): Promise { const runner = detectRunner() - const command = options.command ?? defaultCommand(name, runner) + const command = options.command ?? defaultCommand(options.cli ?? name, runner) const targetAgents = options.agents ?? [] const ampOnly = targetAgents.length === 1 && targetAgents[0] === 'amp' @@ -83,6 +83,8 @@ export declare namespace register { type Options = { /** Target specific agents (e.g. `'claude-code'`, `'cursor'`). */ agents?: string[] | undefined + /** CLI name used to derive the default command. Defaults to the MCP server name. */ + cli?: string | undefined /** Override the command agents will run. Defaults to ` --mcp`. */ command?: string | undefined /** Install globally. Defaults to `true`. */