incur 0.4.8 → 0.4.10

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.
Files changed (88) hide show
  1. package/dist/Cli.d.ts +69 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +419 -73
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +44 -13
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Parser.d.ts +14 -0
  21. package/dist/Parser.d.ts.map +1 -1
  22. package/dist/Parser.js +127 -1
  23. package/dist/Parser.js.map +1 -1
  24. package/dist/Skill.d.ts.map +1 -1
  25. package/dist/Skill.js +5 -2
  26. package/dist/Skill.js.map +1 -1
  27. package/dist/Skillgen.d.ts.map +1 -1
  28. package/dist/Skillgen.js +4 -38
  29. package/dist/Skillgen.js.map +1 -1
  30. package/dist/SyncMcp.d.ts.map +1 -1
  31. package/dist/SyncMcp.js +75 -8
  32. package/dist/SyncMcp.js.map +1 -1
  33. package/dist/SyncSkills.d.ts +9 -0
  34. package/dist/SyncSkills.d.ts.map +1 -1
  35. package/dist/SyncSkills.js +13 -74
  36. package/dist/SyncSkills.js.map +1 -1
  37. package/dist/bin.d.ts +1 -1
  38. package/dist/bin.d.ts.map +1 -1
  39. package/dist/internal/command.d.ts +31 -19
  40. package/dist/internal/command.d.ts.map +1 -1
  41. package/dist/internal/command.js +10 -5
  42. package/dist/internal/command.js.map +1 -1
  43. package/dist/internal/cta.d.ts +22 -0
  44. package/dist/internal/cta.d.ts.map +1 -0
  45. package/dist/internal/cta.js +25 -0
  46. package/dist/internal/cta.js.map +1 -0
  47. package/dist/internal/json.d.ts +5 -0
  48. package/dist/internal/json.d.ts.map +1 -0
  49. package/dist/internal/json.js +13 -0
  50. package/dist/internal/json.js.map +1 -0
  51. package/dist/internal/yaml.d.ts +12 -0
  52. package/dist/internal/yaml.d.ts.map +1 -0
  53. package/dist/internal/yaml.js +20 -0
  54. package/dist/internal/yaml.js.map +1 -0
  55. package/dist/middleware.d.ts +8 -4
  56. package/dist/middleware.d.ts.map +1 -1
  57. package/dist/middleware.js +1 -1
  58. package/dist/middleware.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/Cli.test-d.ts +73 -0
  61. package/src/Cli.test.ts +1536 -48
  62. package/src/Cli.ts +606 -108
  63. package/src/Completions.test.ts +85 -0
  64. package/src/Completions.ts +48 -9
  65. package/src/Formatter.test.ts +17 -0
  66. package/src/Formatter.ts +7 -5
  67. package/src/Help.test.ts +32 -1
  68. package/src/Help.ts +42 -4
  69. package/src/Mcp.test.ts +289 -0
  70. package/src/Mcp.ts +68 -13
  71. package/src/Parser.test.ts +173 -0
  72. package/src/Parser.ts +132 -1
  73. package/src/Skill.test.ts +6 -0
  74. package/src/Skill.ts +8 -5
  75. package/src/Skillgen.test.ts +55 -6
  76. package/src/Skillgen.ts +9 -35
  77. package/src/SyncMcp.test.ts +89 -0
  78. package/src/SyncMcp.ts +72 -8
  79. package/src/SyncSkills.test.ts +78 -1
  80. package/src/SyncSkills.ts +20 -78
  81. package/src/bun-compile.test.ts +5 -0
  82. package/src/e2e.test.ts +166 -12
  83. package/src/internal/command.ts +12 -4
  84. package/src/internal/cta.ts +58 -0
  85. package/src/internal/json.ts +16 -0
  86. package/src/internal/yaml.ts +23 -0
  87. package/src/lazy-imports.test.ts +94 -0
  88. package/src/middleware.ts +12 -3
package/src/Mcp.test.ts CHANGED
@@ -134,6 +134,54 @@ describe('Mcp', () => {
134
134
  expect(echoTool.inputSchema.required).toContain('message')
135
135
  })
136
136
 
137
+ test('tools/list uses command MCP name and description overrides', async () => {
138
+ const commands = new Map<string, any>()
139
+ commands.set('whoami', {
140
+ description: 'Show wallet identity',
141
+ mcp: {
142
+ name: 'get_balance',
143
+ description: 'Get wallet balance',
144
+ },
145
+ run() {
146
+ return { balance: '1.00' }
147
+ },
148
+ })
149
+
150
+ const [, listRes] = await mcpSession(commands, [
151
+ { id: 1, method: 'initialize', params: initParams },
152
+ { id: 2, method: 'tools/list', params: {} },
153
+ ])
154
+ const names = listRes.result.tools.map((tool: any) => tool.name)
155
+ expect(names).toEqual(['get_balance'])
156
+ expect(listRes.result.tools[0].description).toBe('Get wallet balance')
157
+
158
+ const [, callRes] = await mcpSession(commands, [
159
+ { id: 1, method: 'initialize', params: initParams },
160
+ { id: 2, method: 'tools/call', params: { name: 'get_balance', arguments: {} } },
161
+ ])
162
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '{"balance":"1.00"}' }])
163
+ })
164
+
165
+ test('collectTools rejects duplicate MCP tool names', () => {
166
+ const commands = new Map<string, any>()
167
+ commands.set('whoami', {
168
+ mcp: { name: 'get_balance' },
169
+ run() {
170
+ return { balance: '1.00' }
171
+ },
172
+ })
173
+ commands.set('balance', {
174
+ mcp: { name: 'get_balance' },
175
+ run() {
176
+ return { balance: '1.00' }
177
+ },
178
+ })
179
+
180
+ expect(() => Mcp.collectTools(commands, [])).toThrowError(
181
+ 'Duplicate MCP tool name: get_balance',
182
+ )
183
+ })
184
+
137
185
  test('notifications are ignored (no response)', async () => {
138
186
  const responses = await mcpSession(createTestCommands(), [
139
187
  { id: 1, method: 'initialize', params: initParams },
@@ -165,6 +213,18 @@ describe('Mcp', () => {
165
213
  expect(res.result.content).toEqual([{ type: 'text', text: '{"result":"HELLO"}' }])
166
214
  })
167
215
 
216
+ test('tools/call validation error includes fieldErrors', async () => {
217
+ const tool = Mcp.collectTools(createTestCommands(), []).find((tool) => tool.name === 'echo')!
218
+ const result = await Mcp.callTool(tool, { message: 123 })
219
+ expect(result.isError).toBe(true)
220
+ const [content] = result.content
221
+ expect(content).toBeDefined()
222
+ expect(JSON.parse(content!.text)).toMatchObject({
223
+ code: 'VALIDATION_ERROR',
224
+ fieldErrors: [{ code: 'invalid_type', missing: false, path: 'message' }],
225
+ })
226
+ })
227
+
168
228
  test('tools/call with nested group command', async () => {
169
229
  const [, res] = await mcpSession(createTestCommands(), [
170
230
  { id: 1, method: 'initialize', params: initParams },
@@ -177,6 +237,154 @@ describe('Mcp', () => {
177
237
  expect(res.result.content).toEqual([{ type: 'text', text: '{"greeting":"hello world"}' }])
178
238
  })
179
239
 
240
+ test('tools/call serializes bigint values as strings', async () => {
241
+ const commands = new Map<string, any>()
242
+ commands.set('whois', {
243
+ description: 'Return ENS data',
244
+ output: z.object({ expiry: z.bigint() }),
245
+ run() {
246
+ return { expiry: 2461152330n }
247
+ },
248
+ })
249
+
250
+ const [, res] = await mcpSession(commands, [
251
+ { id: 1, method: 'initialize', params: initParams },
252
+ { id: 2, method: 'tools/call', params: { name: 'whois', arguments: {} } },
253
+ ])
254
+ expect(res.result.isError).toBeUndefined()
255
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"expiry":"2461152330"}' }])
256
+ expect(res.result.structuredContent).toEqual({ expiry: '2461152330' })
257
+ })
258
+
259
+ test('tools/list tolerates non-object output schemas', async () => {
260
+ const commands = new Map<string, any>()
261
+ commands.set('list', {
262
+ description: 'List records',
263
+ output: z.array(z.object({ id: z.string() })),
264
+ run() {
265
+ return [{ id: 'foo' }]
266
+ },
267
+ })
268
+ commands.set('count', {
269
+ description: 'Count records',
270
+ output: z.number(),
271
+ run() {
272
+ return 1
273
+ },
274
+ })
275
+ commands.set('show', {
276
+ description: 'Show record',
277
+ output: z.object({ id: z.string() }),
278
+ run() {
279
+ return { id: 'foo' }
280
+ },
281
+ })
282
+
283
+ const tools = Mcp.collectTools(commands, [])
284
+ expect(tools.find((tool) => tool.name === 'list')?.outputSchema).toBeUndefined()
285
+ expect(tools.find((tool) => tool.name === 'count')?.outputSchema).toBeUndefined()
286
+ expect(tools.find((tool) => tool.name === 'show')?.outputSchema?.type).toBe('object')
287
+
288
+ const [, listRes] = await mcpSession(commands, [
289
+ { id: 1, method: 'initialize', params: initParams },
290
+ { id: 2, method: 'tools/list', params: {} },
291
+ ])
292
+ expect(listRes.error).toBeUndefined()
293
+ expect(listRes.result.tools.map((tool: any) => tool.name).sort()).toEqual([
294
+ 'count',
295
+ 'list',
296
+ 'show',
297
+ ])
298
+
299
+ const [, callRes] = await mcpSession(commands, [
300
+ { id: 1, method: 'initialize', params: initParams },
301
+ { id: 2, method: 'tools/call', params: { name: 'list', arguments: {} } },
302
+ ])
303
+ expect(callRes.result.content).toEqual([{ type: 'text', text: '[{"id":"foo"}]' }])
304
+ expect(callRes.result.structuredContent).toBeUndefined()
305
+ })
306
+
307
+ test('tools/call surfaces cta metadata without changing structured content', async () => {
308
+ const commands = new Map<string, any>()
309
+ commands.set('show', {
310
+ description: 'Show a record',
311
+ output: z.object({ id: z.string() }),
312
+ run(c: any) {
313
+ return c.ok(
314
+ { id: 'foo' },
315
+ {
316
+ cta: {
317
+ description: 'Next:',
318
+ commands: [{ command: 'list', description: 'List all' }],
319
+ },
320
+ },
321
+ )
322
+ },
323
+ })
324
+
325
+ const [, res] = await mcpSession(commands, [
326
+ { id: 1, method: 'initialize', params: initParams },
327
+ { id: 2, method: 'tools/call', params: { name: 'show', arguments: {} } },
328
+ ])
329
+
330
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"id":"foo"}' }])
331
+ expect(res.result.structuredContent).toEqual({ id: 'foo' })
332
+ expect(res.result._meta?.cta).toEqual({
333
+ description: 'Next:',
334
+ commands: [{ command: 'test-cli list', description: 'List all' }],
335
+ })
336
+ })
337
+
338
+ test('callTool serializes bigint values as strings', async () => {
339
+ const result = await Mcp.callTool(
340
+ {
341
+ name: 'whois',
342
+ inputSchema: { type: 'object', properties: {} },
343
+ outputSchema: {
344
+ type: 'object',
345
+ properties: { expiry: { type: 'string' } },
346
+ required: ['expiry'],
347
+ },
348
+ command: {
349
+ run() {
350
+ return { expiry: 2461152330n }
351
+ },
352
+ },
353
+ },
354
+ {},
355
+ )
356
+
357
+ expect(result.isError).toBeUndefined()
358
+ expect(result.content).toEqual([{ type: 'text', text: '{"expiry":"2461152330"}' }])
359
+ expect(result.structuredContent).toEqual({ expiry: '2461152330' })
360
+ })
361
+
362
+ test('callTool serializes streamed bigint chunks as strings', async () => {
363
+ const notifications: any[] = []
364
+ const result = await Mcp.callTool(
365
+ {
366
+ name: 'whois',
367
+ inputSchema: { type: 'object', properties: {} },
368
+ command: {
369
+ async *run() {
370
+ yield { expiry: 2461152330n }
371
+ },
372
+ },
373
+ },
374
+ {},
375
+ {
376
+ extra: { mcpReq: { _meta: { progressToken: 'tok-1' } } },
377
+ sendNotification: async (notification) => {
378
+ notifications.push(notification)
379
+ },
380
+ },
381
+ )
382
+
383
+ expect(result.isError).toBeUndefined()
384
+ expect(result.content).toEqual([{ type: 'text', text: '[{"expiry":"2461152330"}]' }])
385
+ expect(notifications[0].params.message).toBe('{"expiry":"2461152330"}')
386
+ })
387
+
180
388
  test('tools/call unknown tool returns error', async () => {
181
389
  const [, res] = await mcpSession(createTestCommands(), [
182
390
  { id: 1, method: 'initialize', params: initParams },
@@ -410,4 +618,85 @@ describe('Mcp', () => {
410
618
  expect(progress[0].params.progress).toBe(1)
411
619
  expect(progress[1].params.progress).toBe(2)
412
620
  })
621
+
622
+ test('serve options.instructions appears in initialize response', async () => {
623
+ const input = new PassThrough()
624
+ const output = new PassThrough()
625
+ const chunks: string[] = []
626
+ output.on('data', (chunk) => chunks.push(chunk.toString()))
627
+
628
+ const done = Mcp.serve('test-cli', '1.0.0', createTestCommands(), {
629
+ input,
630
+ output,
631
+ instructions: 'Use this CLI to run test commands.',
632
+ })
633
+
634
+ input.write(
635
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
636
+ )
637
+ await new Promise((r) => setTimeout(r, 20))
638
+ input.end()
639
+ await done
640
+
641
+ const [res] = chunks.map((c) => JSON.parse(c.trim()))
642
+ expect(res.result.instructions).toBe('Use this CLI to run test commands.')
643
+ })
644
+
645
+ test('command mcp.annotations appear in tools/list', async () => {
646
+ const commands = new Map<string, any>()
647
+ commands.set('read-data', {
648
+ description: 'Read some data',
649
+ mcp: { annotations: { readOnlyHint: true, idempotentHint: true } },
650
+ run: () => ({ data: 42 }),
651
+ })
652
+
653
+ const [, res] = await mcpSession(commands, [
654
+ { id: 1, method: 'initialize', params: initParams },
655
+ { id: 2, method: 'tools/list', params: {} },
656
+ ])
657
+ const tool = res.result.tools.find((t: any) => t.name === 'read-data')
658
+ expect(tool.annotations).toEqual({ readOnlyHint: true, idempotentHint: true })
659
+ })
660
+
661
+ test('command mcp.instructions appear in tools/list as _meta.instructions', async () => {
662
+ const commands = new Map<string, any>()
663
+ commands.set('guided', {
664
+ description: 'A guided command',
665
+ mcp: { instructions: 'Pass a valid JSON payload.' },
666
+ run: () => ({ ok: true }),
667
+ })
668
+
669
+ const [, res] = await mcpSession(commands, [
670
+ { id: 1, method: 'initialize', params: initParams },
671
+ { id: 2, method: 'tools/list', params: {} },
672
+ ])
673
+ const tool = res.result.tools.find((t: any) => t.name === 'guided')
674
+ expect(tool._meta?.instructions).toBe('Pass a valid JSON payload.')
675
+ })
676
+
677
+ test('collectTools extracts annotations and instructions from entry.mcp', () => {
678
+ const commands = new Map<string, any>()
679
+ commands.set('destroy', {
680
+ description: 'Destructive op',
681
+ mcp: {
682
+ annotations: { destructiveHint: true, openWorldHint: false },
683
+ instructions: 'Only call this in dry-run mode.',
684
+ },
685
+ run: () => null,
686
+ })
687
+
688
+ const tools = Mcp.collectTools(commands, [])
689
+ expect(tools).toHaveLength(1)
690
+ expect(tools[0]?.annotations).toEqual({ destructiveHint: true, openWorldHint: false })
691
+ expect(tools[0]?.instructions).toBe('Only call this in dry-run mode.')
692
+ })
693
+
694
+ test('collectTools omits annotations/instructions when not set', () => {
695
+ const commands = new Map<string, any>()
696
+ commands.set('plain', { description: 'No mcp opts', run: () => null })
697
+
698
+ const tools = Mcp.collectTools(commands, [])
699
+ expect(tools[0]?.annotations).toBeUndefined()
700
+ expect(tools[0]?.instructions).toBeUndefined()
701
+ })
413
702
  })
package/src/Mcp.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
2
1
  import type { Readable, Writable } from 'node:stream'
3
2
  import { z } from 'zod'
4
3
 
5
4
  import * as Command from './internal/command.js'
5
+ import { formatCtaBlock, type FormattedCtaBlock } from './internal/cta.js'
6
+ import * as Json from './internal/json.js'
6
7
  import type { Handler as MiddlewareHandler } from './middleware.js'
7
8
  import * as Schema from './Schema.js'
8
9
 
@@ -13,7 +14,14 @@ export async function serve(
13
14
  commands: Map<string, any>,
14
15
  options: serve.Options = {},
15
16
  ): Promise<void> {
16
- const server = new McpServer({ name, version })
17
+ // 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')
20
+
21
+ const server = new McpServer(
22
+ { name, version },
23
+ options.instructions ? { instructions: options.instructions } : undefined,
24
+ )
17
25
 
18
26
  for (const tool of collectTools(commands, [])) {
19
27
  const mergedShape: Record<string, any> = {
@@ -27,7 +35,9 @@ export async function serve(
27
35
  {
28
36
  ...(tool.description ? { description: tool.description } : undefined),
29
37
  ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
30
- ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
38
+ ...(tool.outputSchema ? { outputSchema: fromJsonSchema(tool.outputSchema) } : undefined),
39
+ ...(tool.annotations ? { annotations: tool.annotations } : undefined),
40
+ ...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined),
31
41
  } as never,
32
42
  async (...callArgs: any[]) => {
33
43
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
@@ -67,6 +77,8 @@ export declare namespace serve {
67
77
  vars?: z.ZodObject<any> | undefined
68
78
  /** CLI version string. */
69
79
  version?: string | undefined
80
+ /** Instructions describing how to use the server and its features. */
81
+ instructions?: string | undefined
70
82
  }
71
83
  }
72
84
 
@@ -88,6 +100,7 @@ export async function callTool(
88
100
  ): Promise<{
89
101
  content: { type: 'text'; text: string }[]
90
102
  structuredContent?: Record<string, unknown>
103
+ _meta?: { cta: FormattedCtaBlock } | undefined
91
104
  isError?: boolean
92
105
  }> {
93
106
  const allMiddleware = [
@@ -122,7 +135,7 @@ export async function callTool(
122
135
  if (progressToken !== undefined && options.sendNotification)
123
136
  await options.sendNotification({
124
137
  method: 'notifications/progress' as const,
125
- params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
138
+ params: { progressToken, progress: ++i, message: Json.stringify(chunk) },
126
139
  })
127
140
  }
128
141
  } catch (err) {
@@ -131,21 +144,31 @@ export async function callTool(
131
144
  isError: true,
132
145
  }
133
146
  }
134
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
147
+ return { content: [{ type: 'text', text: Json.stringify(chunks) }] }
135
148
  }
136
149
 
137
150
  if (!result.ok)
138
151
  return {
139
- content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: result.error.fieldErrors
156
+ ? JSON.stringify(result.error)
157
+ : (result.error.message ?? 'Command failed'),
158
+ },
159
+ ],
140
160
  isError: true,
141
161
  }
142
162
 
143
163
  const data = result.data ?? null
164
+ const jsonData = Json.normalize(data)
165
+ const cta = formatCtaBlock(options.name ?? tool.name, result.cta as Command.CtaBlock | undefined)
144
166
  return {
145
- content: [{ type: 'text', text: JSON.stringify(data) }],
167
+ content: [{ type: 'text', text: Json.stringify(jsonData) }],
146
168
  ...(data !== null && tool.outputSchema
147
- ? { structuredContent: data as Record<string, unknown> }
169
+ ? { structuredContent: jsonData as Record<string, unknown> }
148
170
  : undefined),
171
+ ...(cta ? { _meta: { cta } } : undefined),
149
172
  }
150
173
  }
151
174
 
@@ -161,10 +184,26 @@ export type ToolEntry = {
161
184
  description?: string | undefined
162
185
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
163
186
  outputSchema?: Record<string, unknown> | undefined
187
+ annotations?: ToolAnnotations | undefined
188
+ instructions?: string | undefined
164
189
  command: any
165
190
  middlewares?: MiddlewareHandler[] | undefined
166
191
  }
167
192
 
193
+ /** MCP tool annotations that describe tool behavior to clients. */
194
+ export type ToolAnnotations = {
195
+ /** A human-readable title for the tool. */
196
+ title?: string | undefined
197
+ /** If true, the tool does not modify its environment. Default: false. */
198
+ readOnlyHint?: boolean | undefined
199
+ /** If true, the tool may perform destructive updates to its environment. Meaningful only when readOnlyHint is false. Default: true. */
200
+ destructiveHint?: boolean | undefined
201
+ /** If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnlyHint is false. Default: false. */
202
+ idempotentHint?: boolean | undefined
203
+ /** If true, the tool may interact with an open world of external entities. Default: true. */
204
+ openWorldHint?: boolean | undefined
205
+ }
206
+
168
207
  /** @internal Recursively collects leaf commands as tool entries. */
169
208
  export function collectTools(
170
209
  commands: Map<string, any>,
@@ -182,21 +221,37 @@ export function collectTools(
182
221
  ]
183
222
  result.push(...collectTools(entry.commands, path, groupMw))
184
223
  } else {
224
+ const outputSchema = entry.output ? mcpOutputSchema(entry.output) : undefined
185
225
  result.push({
186
- name: path.join('_'),
187
- description: entry.description,
226
+ name: entry.mcp?.name ?? path.join('_'),
227
+ description: entry.mcp?.description ?? entry.description,
188
228
  inputSchema: buildToolSchema(entry.args, entry.options),
189
- ...(entry.output
190
- ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
191
- : undefined),
229
+ ...(outputSchema ? { outputSchema } : undefined),
230
+ ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined),
231
+ ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined),
192
232
  command: entry,
193
233
  ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
194
234
  })
195
235
  }
196
236
  }
237
+ assertUniqueToolNames(result)
197
238
  return result.sort((a, b) => a.name.localeCompare(b.name))
198
239
  }
199
240
 
241
+ function assertUniqueToolNames(tools: ToolEntry[]) {
242
+ const seen = new Set<string>()
243
+ for (const tool of tools) {
244
+ if (seen.has(tool.name)) throw new Error(`Duplicate MCP tool name: ${tool.name}`)
245
+ seen.add(tool.name)
246
+ }
247
+ }
248
+
249
+ function mcpOutputSchema(output: any): Record<string, unknown> | undefined {
250
+ const schema = Schema.toJsonSchema(output) as Record<string, unknown>
251
+ if (schema.type === 'object') return schema
252
+ return undefined
253
+ }
254
+
200
255
  /** @internal Builds a merged JSON Schema from args and options Zod schemas. */
201
256
  function buildToolSchema(
202
257
  args: any | undefined,
@@ -376,3 +376,176 @@ describe('parse', () => {
376
376
  expect(result.options).toEqual({ min: 1, max: 3 })
377
377
  })
378
378
  })
379
+
380
+ describe('parseGlobals', () => {
381
+ test('extracts known globals and returns rest', () => {
382
+ const schema = z.object({ rpcUrl: z.string() })
383
+ const result = Parser.parseGlobals(['--rpc-url', 'http://example.com', 'deploy'], schema)
384
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
385
+ expect(result.rest).toEqual(['deploy'])
386
+ })
387
+
388
+ test('unknown flags pass through to rest', () => {
389
+ const schema = z.object({ rpcUrl: z.string() })
390
+ const result = Parser.parseGlobals(
391
+ ['--rpc-url', 'http://example.com', '--unknown', 'val', 'deploy'],
392
+ schema,
393
+ )
394
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
395
+ expect(result.rest).toEqual(['--unknown', 'val', 'deploy'])
396
+ })
397
+
398
+ test('handles --flag=value syntax', () => {
399
+ const schema = z.object({ rpcUrl: z.string() })
400
+ const result = Parser.parseGlobals(['--rpc-url=http://example.com', 'deploy'], schema)
401
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
402
+ expect(result.rest).toEqual(['deploy'])
403
+ })
404
+
405
+ test('handles short aliases', () => {
406
+ const schema = z.object({ rpcUrl: z.string() })
407
+ const result = Parser.parseGlobals(
408
+ ['-r', 'http://example.com', 'deploy'],
409
+ schema,
410
+ { rpcUrl: 'r' },
411
+ )
412
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
413
+ expect(result.rest).toEqual(['deploy'])
414
+ })
415
+
416
+ test('handles boolean globals', () => {
417
+ const schema = z.object({ verbose: z.boolean().default(false) })
418
+ const result = Parser.parseGlobals(['--verbose', 'deploy'], schema)
419
+ expect(result.parsed).toEqual({ verbose: true })
420
+ expect(result.rest).toEqual(['deploy'])
421
+ })
422
+
423
+ test('validates against schema', () => {
424
+ const schema = z.object({ count: z.number() })
425
+ expect(() => Parser.parseGlobals(['--count', 'not-a-number'], schema)).toThrow()
426
+ })
427
+
428
+ test('coerces string to number', () => {
429
+ const schema = z.object({ limit: z.number() })
430
+ const result = Parser.parseGlobals(['--limit', '42', 'deploy'], schema)
431
+ expect(result.parsed).toEqual({ limit: 42 })
432
+ expect(result.rest).toEqual(['deploy'])
433
+ })
434
+
435
+ test('positionals pass through to rest', () => {
436
+ const schema = z.object({ verbose: z.boolean().default(false) })
437
+ const result = Parser.parseGlobals(['deploy', 'contract', '--verbose'], schema)
438
+ expect(result.parsed).toEqual({ verbose: true })
439
+ expect(result.rest).toEqual(['deploy', 'contract'])
440
+ })
441
+
442
+ test('-- separator: everything after -- passes through to rest including the --', () => {
443
+ const schema = z.object({ verbose: z.boolean().default(false) })
444
+ const result = Parser.parseGlobals(
445
+ ['--verbose', '--', '--unknown', 'positional', '--also-unknown'],
446
+ schema,
447
+ )
448
+ expect(result.parsed).toEqual({ verbose: true })
449
+ expect(result.rest).toEqual(['--', '--unknown', 'positional', '--also-unknown'])
450
+ })
451
+
452
+ test('stacked short aliases: -rv where both are known boolean globals', () => {
453
+ const schema = z.object({
454
+ recursive: z.boolean().default(false),
455
+ verbose: z.boolean().default(false),
456
+ })
457
+ const result = Parser.parseGlobals(['-rv', 'deploy'], schema, {
458
+ recursive: 'r',
459
+ verbose: 'v',
460
+ })
461
+ expect(result.parsed).toEqual({ recursive: true, verbose: true })
462
+ expect(result.rest).toEqual(['deploy'])
463
+ })
464
+
465
+ test('count options: --verbose --verbose accumulates', () => {
466
+ const schema = z.object({ verbose: z.number().default(0).meta({ count: true }) })
467
+ const result = Parser.parseGlobals(['--verbose', '--verbose', 'deploy'], schema)
468
+ expect(result.parsed).toEqual({ verbose: 2 })
469
+ expect(result.rest).toEqual(['deploy'])
470
+ })
471
+
472
+ test('array options: --tag foo --tag bar collects into array', () => {
473
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
474
+ const result = Parser.parseGlobals(['--tag', 'foo', '--tag', 'bar', 'deploy'], schema)
475
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
476
+ expect(result.rest).toEqual(['deploy'])
477
+ })
478
+
479
+ test('unknown --no-* flags pass through to rest', () => {
480
+ const schema = z.object({ verbose: z.boolean().default(false) })
481
+ const result = Parser.parseGlobals(['--no-color', '--verbose'], schema)
482
+ expect(result.parsed).toEqual({ verbose: true })
483
+ expect(result.rest).toEqual(['--no-color'])
484
+ })
485
+
486
+ test('unknown --flag=value passes through as single token', () => {
487
+ const schema = z.object({ verbose: z.boolean().default(false) })
488
+ const result = Parser.parseGlobals(['--output=json', '--verbose'], schema)
489
+ expect(result.parsed).toEqual({ verbose: true })
490
+ expect(result.rest).toEqual(['--output=json'])
491
+ })
492
+
493
+ test('missing value for known flag throws ParseError', () => {
494
+ const schema = z.object({ rpcUrl: z.string() })
495
+ expect(() => Parser.parseGlobals(['--rpc-url'], schema)).toThrow(
496
+ expect.objectContaining({ name: 'Incur.ParseError' }),
497
+ )
498
+ })
499
+
500
+ test('stacked short: count in non-last position', () => {
501
+ const schema = z.object({
502
+ verbose: z.number().default(0).meta({ count: true }),
503
+ recursive: z.boolean().default(false),
504
+ })
505
+ const result = Parser.parseGlobals(['-vr'], schema, { verbose: 'v', recursive: 'r' })
506
+ expect(result.parsed).toEqual({ verbose: 1, recursive: true })
507
+ })
508
+
509
+ test('stacked short: non-boolean in non-last position throws', () => {
510
+ const schema = z.object({
511
+ output: z.string(),
512
+ verbose: z.boolean().default(false),
513
+ })
514
+ expect(() => Parser.parseGlobals(['-ov', 'file'], schema, { output: 'o', verbose: 'v' })).toThrow(
515
+ /must be last/,
516
+ )
517
+ })
518
+
519
+ test('short flag value-taking as last in stacked alias', () => {
520
+ const schema = z.object({
521
+ verbose: z.boolean().default(false),
522
+ output: z.string(),
523
+ })
524
+ const result = Parser.parseGlobals(['-vo', 'file', 'deploy'], schema, {
525
+ verbose: 'v',
526
+ output: 'o',
527
+ })
528
+ expect(result.parsed).toEqual({ verbose: true, output: 'file' })
529
+ expect(result.rest).toEqual(['deploy'])
530
+ })
531
+
532
+ test('short flag missing value throws ParseError', () => {
533
+ const schema = z.object({ output: z.string() })
534
+ expect(() => Parser.parseGlobals(['-o'], schema, { output: 'o' })).toThrow(
535
+ expect.objectContaining({ name: 'Incur.ParseError' }),
536
+ )
537
+ })
538
+
539
+ test('known --no- negation for boolean global', () => {
540
+ const schema = z.object({ verbose: z.boolean().default(true) })
541
+ const result = Parser.parseGlobals(['--no-verbose', 'deploy'], schema)
542
+ expect(result.parsed).toEqual({ verbose: false })
543
+ expect(result.rest).toEqual(['deploy'])
544
+ })
545
+
546
+ test('known --flag=value with setOption', () => {
547
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
548
+ const result = Parser.parseGlobals(['--tag=foo', '--tag=bar'], schema)
549
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
550
+ })
551
+ })