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/dist/Cli.d.ts +4 -0
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +125 -7
- package/dist/Cli.js.map +1 -1
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +38 -6
- package/dist/Mcp.js.map +1 -1
- package/dist/internal/command.d.ts +29 -19
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +4 -0
- package/dist/internal/command.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test-d.ts +2 -0
- package/src/Cli.test.ts +393 -4
- package/src/Cli.ts +150 -7
- package/src/Completions.test.ts +1 -0
- package/src/Help.test.ts +1 -1
- package/src/Mcp.test.ts +105 -2
- package/src/Mcp.ts +58 -7
- package/src/e2e.test.ts +4 -4
- package/src/internal/command.ts +4 -0
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
|
|
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
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1032
|
-
writeln(formatBuiltinHelp(name, b))
|
|
1036
|
+
writeln(formatBuiltinHelp(name, builtin))
|
|
1033
1037
|
return
|
|
1034
1038
|
}
|
|
1035
1039
|
if (help) {
|
|
1036
|
-
|
|
1037
|
-
|
|
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. */
|
package/src/Completions.test.ts
CHANGED
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
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2520
|
+
mcp Register as MCP server (add, doctor)
|
|
2521
2521
|
skills Sync skill files to agents (add, list)
|
|
2522
2522
|
|
|
2523
2523
|
Global Options:
|
package/src/internal/command.ts
CHANGED
|
@@ -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
|
{
|