incur 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs/promises'
2
2
  import * as os from 'node:os'
3
3
  import * as path from 'node:path'
4
+ import { PassThrough } from 'node:stream'
4
5
  import { estimateTokenCount, sliceByTokens } from 'tokenx'
5
6
  import { z } from 'zod'
6
7
 
@@ -1002,12 +1003,16 @@ async function serveImpl(
1002
1003
  return
1003
1004
  }
1004
1005
 
1005
- // mcp add: register CLI as MCP server via `npx add-mcp`
1006
+ // mcp add/doctor: register or smoke-test CLI MCP server integration.
1006
1007
  const mcpIdx = builtinIdx(filtered, name, 'mcp')
1007
1008
  if (mcpIdx !== -1) {
1009
+ const builtin = findBuiltin('mcp')!
1008
1010
  const mcpSub = filtered[mcpIdx + 1]
1009
- if (mcpSub && mcpSub !== 'add') {
1010
- const suggestion = suggest(mcpSub, ['add'])
1011
+ const sub = mcpSub ? findBuiltinSubcommand(builtin, mcpSub) : undefined
1012
+ if (mcpSub && !sub) {
1013
+ const candidates =
1014
+ builtin.subcommands?.flatMap((sub) => [sub.name, ...(sub.aliases ?? [])]) ?? []
1015
+ const suggestion = suggest(mcpSub, candidates)
1011
1016
  const didYouMean = suggestion ? ` Did you mean '${suggestion}'?` : ''
1012
1017
  const message = `'${mcpSub}' is not a command for '${name} mcp'.${didYouMean}`
1013
1018
  const ctaCommands: FormattedCta[] = []
@@ -1028,13 +1033,17 @@ async function serveImpl(
1028
1033
  return
1029
1034
  }
1030
1035
  if (!mcpSub) {
1031
- const b = findBuiltin('mcp')!
1032
- writeln(formatBuiltinHelp(name, b))
1036
+ writeln(formatBuiltinHelp(name, builtin))
1033
1037
  return
1034
1038
  }
1035
1039
  if (help) {
1036
- const b = findBuiltin('mcp')!
1037
- writeln(formatBuiltinSubcommandHelp(name, b, 'add'))
1040
+ writeln(formatBuiltinSubcommandHelp(name, builtin, sub!.name))
1041
+ return
1042
+ }
1043
+ if (sub!.name === 'doctor') {
1044
+ const result = await runMcpDoctor(name, commands, options)
1045
+ writeln(Formatter.format(result, formatExplicit ? formatFlag : 'toon'))
1046
+ if (!result.ok) exit(1)
1038
1047
  return
1039
1048
  }
1040
1049
  const rest = filtered.slice(mcpIdx + 2)
@@ -2713,6 +2722,136 @@ function formatBuiltinSubcommandHelp(
2713
2722
  })
2714
2723
  }
2715
2724
 
2725
+ type McpDoctorResult = {
2726
+ ok: boolean
2727
+ toolCount: number
2728
+ tools: { name: string; description?: string | undefined }[]
2729
+ warnings: string[]
2730
+ errors: { code: string; message: string }[]
2731
+ }
2732
+
2733
+ async function runMcpDoctor(
2734
+ name: string,
2735
+ commands: Map<string, CommandEntry>,
2736
+ options: serveImpl.Options,
2737
+ ): Promise<McpDoctorResult> {
2738
+ const warnings: string[] = []
2739
+ const errors: McpDoctorResult['errors'] = []
2740
+ const input = new PassThrough()
2741
+ const output = new PassThrough()
2742
+ const chunks: string[] = []
2743
+ output.on('data', (chunk) => chunks.push(chunk.toString()))
2744
+
2745
+ let serveError: unknown
2746
+ const done = Mcp.serve(name, options.version ?? '0.0.0', commands, {
2747
+ input,
2748
+ output,
2749
+ middlewares: options.middlewares,
2750
+ env: options.envSchema,
2751
+ vars: options.vars,
2752
+ version: options.version,
2753
+ ...(options.mcp?.instructions ? { instructions: options.mcp.instructions } : undefined),
2754
+ }).catch((error) => {
2755
+ serveError = error
2756
+ })
2757
+
2758
+ input.write(
2759
+ `${Json.stringify({
2760
+ jsonrpc: '2.0',
2761
+ id: 1,
2762
+ method: 'initialize',
2763
+ params: {
2764
+ protocolVersion: '2024-11-05',
2765
+ capabilities: {},
2766
+ clientInfo: { name: 'incur-doctor', version: '1.0.0' },
2767
+ },
2768
+ })}\n`,
2769
+ )
2770
+ input.write(`${Json.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`)
2771
+ await new Promise((resolve) => setTimeout(resolve, 20))
2772
+ input.end()
2773
+ await done
2774
+
2775
+ if (serveError)
2776
+ return {
2777
+ ok: false,
2778
+ toolCount: 0,
2779
+ tools: [],
2780
+ warnings,
2781
+ errors: [{ code: 'MCP_SERVER_FAILED', message: errorMessage(serveError) }],
2782
+ }
2783
+
2784
+ let responses: Record<string, unknown>[]
2785
+ try {
2786
+ responses = parseMcpDoctorResponses(chunks)
2787
+ } catch (error) {
2788
+ return {
2789
+ ok: false,
2790
+ toolCount: 0,
2791
+ tools: [],
2792
+ warnings,
2793
+ errors: [{ code: 'MCP_RESPONSE_PARSE_FAILED', message: errorMessage(error) }],
2794
+ }
2795
+ }
2796
+
2797
+ const initialize = responses.find((response) => response.id === 1)
2798
+ if (!initialize)
2799
+ errors.push({ code: 'MCP_INITIALIZE_MISSING', message: 'Missing initialize response.' })
2800
+ else if (initialize.error)
2801
+ errors.push({ code: 'MCP_INITIALIZE_FAILED', message: mcpErrorMessage(initialize.error) })
2802
+
2803
+ const toolsList = responses.find((response) => response.id === 2)
2804
+ let tools: McpDoctorResult['tools'] = []
2805
+ if (!toolsList)
2806
+ errors.push({ code: 'MCP_TOOLS_LIST_MISSING', message: 'Missing tools/list response.' })
2807
+ else if (toolsList.error)
2808
+ errors.push({ code: 'MCP_TOOLS_LIST_FAILED', message: mcpErrorMessage(toolsList.error) })
2809
+ else if (!isRecord(toolsList.result) || !Array.isArray(toolsList.result.tools))
2810
+ errors.push({
2811
+ code: 'MCP_TOOLS_LIST_INVALID',
2812
+ message: 'tools/list did not return a tools array.',
2813
+ })
2814
+ else
2815
+ tools = toolsList.result.tools
2816
+ .filter(isRecord)
2817
+ .map((tool) => ({
2818
+ name: typeof tool.name === 'string' ? tool.name : '',
2819
+ ...(typeof tool.description === 'string' ? { description: tool.description } : undefined),
2820
+ }))
2821
+ .filter((tool) => tool.name)
2822
+
2823
+ if (errors.length === 0 && tools.length === 0) warnings.push('No MCP tools exposed.')
2824
+
2825
+ return {
2826
+ ok: errors.length === 0,
2827
+ toolCount: tools.length,
2828
+ tools,
2829
+ warnings,
2830
+ errors,
2831
+ }
2832
+ }
2833
+
2834
+ function parseMcpDoctorResponses(chunks: string[]): Record<string, unknown>[] {
2835
+ const responses: Record<string, unknown>[] = []
2836
+ for (const line of chunks.join('').split(/\r?\n/)) {
2837
+ const trimmed = line.trim()
2838
+ if (!trimmed) continue
2839
+ const parsed = JSON.parse(trimmed)
2840
+ if (!isRecord(parsed)) throw new Error('Expected JSON-RPC response object.')
2841
+ responses.push(parsed)
2842
+ }
2843
+ return responses
2844
+ }
2845
+
2846
+ function errorMessage(error: unknown): string {
2847
+ return error instanceof Error ? error.message : String(error)
2848
+ }
2849
+
2850
+ function mcpErrorMessage(error: unknown): string {
2851
+ if (isRecord(error) && typeof error.message === 'string') return error.message
2852
+ return Json.stringify(error)
2853
+ }
2854
+
2716
2855
  /** @internal Formats help text for a fetch gateway command. */
2717
2856
  function formatFetchHelp(name: string, description?: string): string {
2718
2857
  const lines: string[] = []
@@ -3492,6 +3631,10 @@ type CommandDefinition<
3492
3631
  /** MCP-specific metadata exposed when this command is served as a tool. */
3493
3632
  mcp?:
3494
3633
  | {
3634
+ /** Override the command name exposed to MCP clients. */
3635
+ name?: string | undefined
3636
+ /** Override the command description exposed to MCP clients. */
3637
+ description?: string | undefined
3495
3638
  /** MCP tool annotations that describe tool behavior to clients. */
3496
3639
  annotations?: Mcp.ToolAnnotations | undefined
3497
3640
  /** Tool-specific instructions surfaced to MCP clients. */
@@ -599,6 +599,7 @@ describe('serve integration', () => {
599
599
  _COMPLETE_INDEX: '2',
600
600
  })
601
601
  expect(output).toContain('add')
602
+ expect(output).toContain('doctor')
602
603
  })
603
604
 
604
605
  test('COMPLETE=zsh with words outputs candidates in zsh format', async () => {
package/src/Help.test.ts CHANGED
@@ -392,7 +392,7 @@ describe('formatRoot', () => {
392
392
 
393
393
  Integrations:
394
394
  completions Generate shell completion script
395
- mcp add Register as MCP server
395
+ mcp Register as MCP server (add, doctor)
396
396
  skills Sync skill files to agents (add, list)
397
397
 
398
398
  Global Options:
package/src/Mcp.test.ts CHANGED
@@ -84,14 +84,21 @@ async function mcpSession(
84
84
  input.write(`${JSON.stringify(rpc)}\n`)
85
85
  }
86
86
 
87
- // Give time for async processing then close
88
- await new Promise((r) => setTimeout(r, 20))
87
+ await waitForResponses(chunks, messages.filter((msg) => msg.id !== undefined).length)
89
88
  input.end()
90
89
  await done
91
90
 
92
91
  return chunks.map((c) => JSON.parse(c.trim()))
93
92
  }
94
93
 
94
+ async function waitForResponses(chunks: string[], expected: number) {
95
+ const started = Date.now()
96
+ while (chunks.length < expected) {
97
+ if (Date.now() - started > 1_000) return
98
+ await new Promise((r) => setTimeout(r, 5))
99
+ }
100
+ }
101
+
95
102
  describe('Mcp', () => {
96
103
  test('initialize responds with server info', async () => {
97
104
  const [res] = await mcpSession(createTestCommands(), [
@@ -134,6 +141,54 @@ describe('Mcp', () => {
134
141
  expect(echoTool.inputSchema.required).toContain('message')
135
142
  })
136
143
 
144
+ test('tools/list uses command MCP name and description overrides', async () => {
145
+ const commands = new Map<string, any>()
146
+ commands.set('whoami', {
147
+ description: 'Show wallet identity',
148
+ mcp: {
149
+ name: 'get_balance',
150
+ description: 'Get wallet balance',
151
+ },
152
+ run() {
153
+ return { balance: '1.00' }
154
+ },
155
+ })
156
+
157
+ const [, listRes] = await mcpSession(commands, [
158
+ { id: 1, method: 'initialize', params: initParams },
159
+ { id: 2, method: 'tools/list', params: {} },
160
+ ])
161
+ const names = listRes.result.tools.map((tool: any) => tool.name)
162
+ expect(names).toEqual(['get_balance'])
163
+ expect(listRes.result.tools[0].description).toBe('Get wallet balance')
164
+
165
+ const [, callRes] = await mcpSession(commands, [
166
+ { id: 1, method: 'initialize', params: initParams },
167
+ { id: 2, method: 'tools/call', params: { name: 'get_balance', arguments: {} } },
168
+ ])
169
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"balance":"1.00"}' }])
170
+ })
171
+
172
+ test('collectTools rejects duplicate MCP tool names', () => {
173
+ const commands = new Map<string, any>()
174
+ commands.set('whoami', {
175
+ mcp: { name: 'get_balance' },
176
+ run() {
177
+ return { balance: '1.00' }
178
+ },
179
+ })
180
+ commands.set('balance', {
181
+ mcp: { name: 'get_balance' },
182
+ run() {
183
+ return { balance: '1.00' }
184
+ },
185
+ })
186
+
187
+ expect(() => Mcp.collectTools(commands, [])).toThrowError(
188
+ 'Duplicate MCP tool name: get_balance',
189
+ )
190
+ })
191
+
137
192
  test('notifications are ignored (no response)', async () => {
138
193
  const responses = await mcpSession(createTestCommands(), [
139
194
  { id: 1, method: 'initialize', params: initParams },
@@ -208,6 +263,54 @@ describe('Mcp', () => {
208
263
  expect(res.result.structuredContent).toEqual({ expiry: '2461152330' })
209
264
  })
210
265
 
266
+ test('tools/list tolerates non-object output schemas', async () => {
267
+ const commands = new Map<string, any>()
268
+ commands.set('list', {
269
+ description: 'List records',
270
+ output: z.array(z.object({ id: z.string() })),
271
+ run() {
272
+ return [{ id: 'foo' }]
273
+ },
274
+ })
275
+ commands.set('count', {
276
+ description: 'Count records',
277
+ output: z.number(),
278
+ run() {
279
+ return 1
280
+ },
281
+ })
282
+ commands.set('show', {
283
+ description: 'Show record',
284
+ output: z.object({ id: z.string() }),
285
+ run() {
286
+ return { id: 'foo' }
287
+ },
288
+ })
289
+
290
+ const tools = Mcp.collectTools(commands, [])
291
+ expect(tools.find((tool) => tool.name === 'list')?.outputSchema).toBeUndefined()
292
+ expect(tools.find((tool) => tool.name === 'count')?.outputSchema).toBeUndefined()
293
+ expect(tools.find((tool) => tool.name === 'show')?.outputSchema?.type).toBe('object')
294
+
295
+ const [, listRes] = await mcpSession(commands, [
296
+ { id: 1, method: 'initialize', params: initParams },
297
+ { id: 2, method: 'tools/list', params: {} },
298
+ ])
299
+ expect(listRes.error).toBeUndefined()
300
+ expect(listRes.result.tools.map((tool: any) => tool.name).sort()).toEqual([
301
+ 'count',
302
+ 'list',
303
+ 'show',
304
+ ])
305
+
306
+ const [, callRes] = await mcpSession(commands, [
307
+ { id: 1, method: 'initialize', params: initParams },
308
+ { id: 2, method: 'tools/call', params: { name: 'list', arguments: {} } },
309
+ ])
310
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '[{"id":"foo"}]' }])
311
+ expect(callRes.result.structuredContent).toBeUndefined()
312
+ })
313
+
211
314
  test('tools/call surfaces cta metadata without changing structured content', async () => {
212
315
  const commands = new Map<string, any>()
213
316
  commands.set('show', {
package/src/Mcp.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Transport } from '@modelcontextprotocol/server'
1
2
  import type { Readable, Writable } from 'node:stream'
2
3
  import { z } from 'zod'
3
4
 
@@ -15,8 +16,10 @@ export async function serve(
15
16
  options: serve.Options = {},
16
17
  ): Promise<void> {
17
18
  // Lazy: only runs when actually serving MCP, so plain command runs don't pay for the SDK import.
18
- const { fromJsonSchema, McpServer, StdioServerTransport } =
19
- await import('@modelcontextprotocol/server')
19
+ const stdio = importStdioModule()
20
+ const mcp = await import('@modelcontextprotocol/server')
21
+ const { fromJsonSchema, McpServer } = mcp
22
+ const StdioServerTransport = await importStdioServerTransport(mcp, stdio)
20
23
 
21
24
  const server = new McpServer(
22
25
  { name, version },
@@ -62,6 +65,40 @@ export async function serve(
62
65
  await server.connect(transport)
63
66
  }
64
67
 
68
+ type StdioServerTransport = new (
69
+ input?: Readable,
70
+ output?: Writable,
71
+ options?: { maxBufferSize?: number | undefined },
72
+ ) => Transport
73
+
74
+ type StdioModule = {
75
+ StdioServerTransport: StdioServerTransport
76
+ }
77
+
78
+ type StdioImportResult =
79
+ | { module: unknown; error?: undefined }
80
+ | { module?: undefined; error: unknown }
81
+
82
+ async function importStdioServerTransport(
83
+ mcp: unknown,
84
+ stdio: Promise<StdioImportResult>,
85
+ ): Promise<StdioServerTransport> {
86
+ const transport = (mcp as Partial<StdioModule>).StdioServerTransport
87
+ if (transport) return transport
88
+
89
+ const result = await stdio
90
+ if (result.error) throw result.error
91
+ return (result.module as StdioModule).StdioServerTransport
92
+ }
93
+
94
+ function importStdioModule(): Promise<StdioImportResult> {
95
+ return importModule('@modelcontextprotocol/server/stdio')
96
+ .then((module) => ({ module }))
97
+ .catch((error: unknown) => ({ error }))
98
+ }
99
+
100
+ const importModule = (specifier: string): Promise<unknown> => import(specifier)
101
+
65
102
  export declare namespace serve {
66
103
  /** Options for the MCP server. */
67
104
  type Options = {
@@ -221,13 +258,12 @@ export function collectTools(
221
258
  ]
222
259
  result.push(...collectTools(entry.commands, path, groupMw))
223
260
  } else {
261
+ const outputSchema = entry.output ? mcpOutputSchema(entry.output) : undefined
224
262
  result.push({
225
- name: path.join('_'),
226
- description: entry.description,
263
+ name: entry.mcp?.name ?? path.join('_'),
264
+ description: entry.mcp?.description ?? entry.description,
227
265
  inputSchema: buildToolSchema(entry.args, entry.options),
228
- ...(entry.output
229
- ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
230
- : undefined),
266
+ ...(outputSchema ? { outputSchema } : undefined),
231
267
  ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined),
232
268
  ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined),
233
269
  command: entry,
@@ -235,9 +271,24 @@ export function collectTools(
235
271
  })
236
272
  }
237
273
  }
274
+ assertUniqueToolNames(result)
238
275
  return result.sort((a, b) => a.name.localeCompare(b.name))
239
276
  }
240
277
 
278
+ function assertUniqueToolNames(tools: ToolEntry[]) {
279
+ const seen = new Set<string>()
280
+ for (const tool of tools) {
281
+ if (seen.has(tool.name)) throw new Error(`Duplicate MCP tool name: ${tool.name}`)
282
+ seen.add(tool.name)
283
+ }
284
+ }
285
+
286
+ function mcpOutputSchema(output: any): Record<string, unknown> | undefined {
287
+ const schema = Schema.toJsonSchema(output) as Record<string, unknown>
288
+ if (schema.type === 'object') return schema
289
+ return undefined
290
+ }
291
+
241
292
  /** @internal Builds a merged JSON Schema from args and options Zod schemas. */
242
293
  function buildToolSchema(
243
294
  args: any | undefined,
package/src/e2e.test.ts CHANGED
@@ -984,7 +984,7 @@ describe('help', () => {
984
984
 
985
985
  Integrations:
986
986
  completions Generate shell completion script
987
- mcp add Register as MCP server
987
+ mcp Register as MCP server (add, doctor)
988
988
  skills Sync skill files to agents (add, list)
989
989
 
990
990
  Global Options:
@@ -1759,7 +1759,7 @@ describe('root command with subcommands', () => {
1759
1759
 
1760
1760
  Integrations:
1761
1761
  completions Generate shell completion script
1762
- mcp add Register as MCP server
1762
+ mcp Register as MCP server (add, doctor)
1763
1763
  skills Sync skill files to agents (add, list)
1764
1764
 
1765
1765
  Global Options:
@@ -2436,7 +2436,7 @@ describe('hosted OpenAPI CLI', () => {
2436
2436
 
2437
2437
  Integrations:
2438
2438
  completions Generate shell completion script
2439
- mcp add Register as MCP server
2439
+ mcp Register as MCP server (add, doctor)
2440
2440
  skills Sync skill files to agents (add, list)
2441
2441
 
2442
2442
  Global Options:
@@ -2517,7 +2517,7 @@ describe('hosted OpenAPI CLI', () => {
2517
2517
 
2518
2518
  Integrations:
2519
2519
  completions Generate shell completion script
2520
- mcp add Register as MCP server
2520
+ mcp Register as MCP server (add, doctor)
2521
2521
  skills Sync skill files to agents (add, list)
2522
2522
 
2523
2523
  Global Options:
@@ -419,6 +419,10 @@ export const builtinCommands = [
419
419
  noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
420
420
  }),
421
421
  }),
422
+ subcommand({
423
+ name: 'doctor',
424
+ description: 'Validate MCP server startup and tool listing',
425
+ }),
422
426
  ],
423
427
  },
424
428
  {