incur 0.4.7 → 0.4.9

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 (94) hide show
  1. package/dist/Cli.d.ts +65 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +294 -66
  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 +25 -8
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Openapi.d.ts +14 -0
  21. package/dist/Openapi.d.ts.map +1 -1
  22. package/dist/Openapi.js +85 -1
  23. package/dist/Openapi.js.map +1 -1
  24. package/dist/Parser.d.ts +14 -0
  25. package/dist/Parser.d.ts.map +1 -1
  26. package/dist/Parser.js +127 -1
  27. package/dist/Parser.js.map +1 -1
  28. package/dist/Skill.d.ts.map +1 -1
  29. package/dist/Skill.js +5 -2
  30. package/dist/Skill.js.map +1 -1
  31. package/dist/Skillgen.d.ts.map +1 -1
  32. package/dist/Skillgen.js +4 -38
  33. package/dist/Skillgen.js.map +1 -1
  34. package/dist/SyncMcp.d.ts.map +1 -1
  35. package/dist/SyncMcp.js +75 -8
  36. package/dist/SyncMcp.js.map +1 -1
  37. package/dist/SyncSkills.d.ts +9 -0
  38. package/dist/SyncSkills.d.ts.map +1 -1
  39. package/dist/SyncSkills.js +13 -74
  40. package/dist/SyncSkills.js.map +1 -1
  41. package/dist/bin.d.ts +1 -1
  42. package/dist/bin.d.ts.map +1 -1
  43. package/dist/internal/command.d.ts +2 -0
  44. package/dist/internal/command.d.ts.map +1 -1
  45. package/dist/internal/command.js +6 -5
  46. package/dist/internal/command.js.map +1 -1
  47. package/dist/internal/cta.d.ts +22 -0
  48. package/dist/internal/cta.d.ts.map +1 -0
  49. package/dist/internal/cta.js +25 -0
  50. package/dist/internal/cta.js.map +1 -0
  51. package/dist/internal/json.d.ts +5 -0
  52. package/dist/internal/json.d.ts.map +1 -0
  53. package/dist/internal/json.js +13 -0
  54. package/dist/internal/json.js.map +1 -0
  55. package/dist/internal/yaml.d.ts +12 -0
  56. package/dist/internal/yaml.d.ts.map +1 -0
  57. package/dist/internal/yaml.js +20 -0
  58. package/dist/internal/yaml.js.map +1 -0
  59. package/dist/middleware.d.ts +8 -4
  60. package/dist/middleware.d.ts.map +1 -1
  61. package/dist/middleware.js +1 -1
  62. package/dist/middleware.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/Cli.test-d.ts +71 -0
  65. package/src/Cli.test.ts +1164 -65
  66. package/src/Cli.ts +456 -101
  67. package/src/Completions.test.ts +84 -0
  68. package/src/Completions.ts +48 -9
  69. package/src/Formatter.test.ts +17 -0
  70. package/src/Formatter.ts +7 -5
  71. package/src/Help.test.ts +31 -0
  72. package/src/Help.ts +42 -4
  73. package/src/Mcp.test.ts +193 -0
  74. package/src/Mcp.ts +49 -8
  75. package/src/Openapi.test.ts +179 -0
  76. package/src/Openapi.ts +122 -2
  77. package/src/Parser.test.ts +173 -0
  78. package/src/Parser.ts +132 -1
  79. package/src/Skill.test.ts +6 -0
  80. package/src/Skill.ts +8 -5
  81. package/src/Skillgen.test.ts +55 -6
  82. package/src/Skillgen.ts +9 -35
  83. package/src/SyncMcp.test.ts +89 -0
  84. package/src/SyncMcp.ts +72 -8
  85. package/src/SyncSkills.test.ts +78 -1
  86. package/src/SyncSkills.ts +20 -78
  87. package/src/bun-compile.test.ts +5 -0
  88. package/src/e2e.test.ts +162 -8
  89. package/src/internal/command.ts +8 -4
  90. package/src/internal/cta.ts +58 -0
  91. package/src/internal/json.ts +16 -0
  92. package/src/internal/yaml.ts +23 -0
  93. package/src/lazy-imports.test.ts +94 -0
  94. package/src/middleware.ts +12 -3
@@ -226,6 +226,90 @@ describe('complete', () => {
226
226
  })
227
227
  })
228
228
 
229
+ describe('complete with globals', () => {
230
+ const globalSchema = z.object({
231
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
232
+ chain: z.enum(['mainnet', 'sepolia', 'goerli']).default('mainnet').describe('Target chain'),
233
+ dryRun: z.boolean().default(false).describe('Dry run mode'),
234
+ })
235
+ const globalAlias = { rpcUrl: 'r', dryRun: 'd' }
236
+ const globals = { schema: globalSchema, alias: globalAlias }
237
+
238
+ test('global flags appear as completion candidates when typing --', () => {
239
+ const cli = makeCli()
240
+ const commands = Cli.toCommands.get(cli)!
241
+ const candidates = Completions.complete(commands, undefined, ['mycli', 'build', '--'], 2, globals)
242
+ const values = candidates.map((c) => c.value)
243
+ expect(values).toContain('--rpc-url')
244
+ expect(values).toContain('--chain')
245
+ expect(values).toContain('--dry-run')
246
+ })
247
+
248
+ test('global short aliases appear as candidates when typing -', () => {
249
+ const cli = makeCli()
250
+ const commands = Cli.toCommands.get(cli)!
251
+ const candidates = Completions.complete(commands, undefined, ['mycli', 'build', '-'], 2, globals)
252
+ const values = candidates.map((c) => c.value)
253
+ expect(values).toContain('-r')
254
+ expect(values).toContain('-d')
255
+ })
256
+
257
+ test('global flags do not duplicate command-specific flags', () => {
258
+ // Create a CLI where a command has --chain already
259
+ const cli = Cli.create('mycli', { version: '1.0.0' })
260
+ cli.command('deploy', {
261
+ description: 'Deploy',
262
+ options: z.object({
263
+ chain: z.string().optional().describe('Chain override'),
264
+ }),
265
+ run: () => ({}),
266
+ })
267
+ const commands = Cli.toCommands.get(cli)!
268
+ const rootCmd = { options: z.object({ chain: z.string().optional() }) }
269
+ const dupeGlobals = { schema: z.object({ chain: z.string().optional().describe('Global chain') }) }
270
+ const candidates = Completions.complete(
271
+ commands,
272
+ rootCmd,
273
+ ['mycli', 'deploy', '--'],
274
+ 2,
275
+ dupeGlobals,
276
+ )
277
+ const chainCount = candidates.filter((c) => c.value === '--chain').length
278
+ expect(chainCount).toBe(1)
279
+ })
280
+
281
+ test('global option value completion for enum', () => {
282
+ const cli = makeCli()
283
+ const commands = Cli.toCommands.get(cli)!
284
+ const candidates = Completions.complete(
285
+ commands,
286
+ undefined,
287
+ ['mycli', 'build', '--chain', ''],
288
+ 3,
289
+ globals,
290
+ )
291
+ const values = candidates.map((c) => c.value)
292
+ expect(values).toEqual(['mainnet', 'sepolia', 'goerli'])
293
+ })
294
+
295
+ test('global boolean flag does not consume next token as value in completions', () => {
296
+ const cli = makeCli()
297
+ const commands = Cli.toCommands.get(cli)!
298
+ // After --dry-run (boolean), the next token should suggest subcommands, not be consumed as value
299
+ const candidates = Completions.complete(
300
+ commands,
301
+ undefined,
302
+ ['mycli', '--dry-run', ''],
303
+ 2,
304
+ globals,
305
+ )
306
+ const values = candidates.map((c) => c.value)
307
+ // Should suggest subcommands since --dry-run is boolean and doesn't consume next token
308
+ expect(values).toContain('build')
309
+ expect(values).toContain('test')
310
+ })
311
+ })
312
+
229
313
  describe('format', () => {
230
314
  const candidates: Completions.Candidate[] = [
231
315
  { value: '--target', description: 'Build target' },
@@ -1,6 +1,8 @@
1
1
  import type { z } from 'zod'
2
2
 
3
+ import type { GlobalsDescriptor } from './Cli.js'
3
4
  import type { Shell } from './internal/command.js'
5
+ import { toKebab } from './internal/helpers.js'
4
6
 
5
7
  /** A completion candidate with an optional description. */
6
8
  export type Candidate = {
@@ -41,6 +43,7 @@ export function register(shell: Shell, name: string): string {
41
43
  }
42
44
  }
43
45
 
46
+
44
47
  /**
45
48
  * Computes completion candidates for the given argv words and cursor index.
46
49
  * Walks the command tree to resolve the active command, then suggests
@@ -51,6 +54,7 @@ export function complete(
51
54
  rootCommand: CommandEntry | undefined,
52
55
  argv: string[],
53
56
  index: number,
57
+ globals?: GlobalsDescriptor | undefined,
54
58
  ): Candidate[] {
55
59
  const current = argv[index] ?? ''
56
60
 
@@ -84,7 +88,7 @@ export function complete(
84
88
  if (leaf?.options) {
85
89
  const shape = leaf.options.shape as Record<string, any>
86
90
  for (const key of Object.keys(shape)) {
87
- const kebab = key.replace(/[A-Z]/g, (c: string) => `-${c.toLowerCase()}`)
91
+ const kebab = toKebab(key)
88
92
  const flag = `--${kebab}`
89
93
  if (flag.startsWith(current))
90
94
  candidates.push({ value: flag, description: descriptionOf(shape[key]) })
@@ -99,6 +103,25 @@ export function complete(
99
103
  }
100
104
  }
101
105
  }
106
+ // Global options
107
+ if (globals) {
108
+ const globalShape = globals.schema.shape as Record<string, any>
109
+ for (const key of Object.keys(globalShape)) {
110
+ const kebab = toKebab(key)
111
+ const flag = `--${kebab}`
112
+ if (flag.startsWith(current) && !candidates.some((c) => c.value === flag))
113
+ candidates.push({ value: flag, description: descriptionOf(globalShape[key]) })
114
+ }
115
+ // Global short aliases
116
+ if (globals.alias)
117
+ for (const [name, short] of Object.entries(globals.alias)) {
118
+ const flag = `-${short}`
119
+ if (flag.startsWith(current) && !candidates.some((c) => c.value === flag)) {
120
+ const desc = descriptionOf(globalShape[name])
121
+ candidates.push({ value: flag, description: desc })
122
+ }
123
+ }
124
+ }
102
125
  return candidates
103
126
  }
104
127
 
@@ -106,15 +129,31 @@ export function complete(
106
129
  if (index > 0) {
107
130
  const prev = argv[index - 1]!
108
131
  const leaf = scope.leaf
109
- if (leaf?.options && prev.startsWith('-')) {
110
- const name = resolveOptionName(prev, leaf)
111
- if (name) {
112
- const values = possibleValues(name, leaf.options)
113
- if (values) {
114
- for (const v of values) if (v.startsWith(current)) candidates.push({ value: v })
115
- return candidates
132
+ if (prev.startsWith('-')) {
133
+ // Try command-specific options first
134
+ if (leaf?.options) {
135
+ const name = resolveOptionName(prev, leaf)
136
+ if (name) {
137
+ const values = possibleValues(name, leaf.options)
138
+ if (values) {
139
+ for (const v of values) if (v.startsWith(current)) candidates.push({ value: v })
140
+ return candidates
141
+ }
142
+ if (!isBooleanOption(name, leaf.options)) return candidates
143
+ }
144
+ }
145
+ // Try global options
146
+ if (globals) {
147
+ const globalEntry: CommandEntry = { options: globals.schema, alias: globals.alias }
148
+ const name = resolveOptionName(prev, globalEntry)
149
+ if (name) {
150
+ const values = possibleValues(name, globals.schema)
151
+ if (values) {
152
+ for (const v of values) if (v.startsWith(current)) candidates.push({ value: v })
153
+ return candidates
154
+ }
155
+ if (!isBooleanOption(name, globals.schema)) return candidates
116
156
  }
117
- if (!isBooleanOption(name, leaf.options)) return candidates
118
157
  }
119
158
  }
120
159
  }
@@ -62,6 +62,15 @@ describe('format', () => {
62
62
  `)
63
63
  })
64
64
 
65
+ test('formats bigint values as strings in JSON', () => {
66
+ const result = Formatter.format({ expiry: 2461152330n }, 'json')
67
+ expect(result).toMatchInlineSnapshot(`
68
+ "{
69
+ "expiry": "2461152330"
70
+ }"
71
+ `)
72
+ })
73
+
65
74
  test('formats as YAML', () => {
66
75
  const result = Formatter.format({ message: 'hello' }, 'yaml')
67
76
  expect(result).toMatchInlineSnapshot(`
@@ -319,6 +328,14 @@ describe('format jsonl', () => {
319
328
  `)
320
329
  })
321
330
 
331
+ test('array outputs bigint values as strings', () => {
332
+ const result = Formatter.format([{ expiry: 2461152330n }, { expiry: 2461152331n }], 'jsonl')
333
+ expect(result).toMatchInlineSnapshot(`
334
+ "{"expiry":"2461152330"}
335
+ {"expiry":"2461152331"}"
336
+ `)
337
+ })
338
+
322
339
  test('scalar value outputs JSON', () => {
323
340
  expect(Formatter.format(42, 'jsonl')).toMatchInlineSnapshot(`"42"`)
324
341
  expect(Formatter.format('hi', 'jsonl')).toMatchInlineSnapshot(`""hi""`)
package/src/Formatter.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { encode } from '@toon-format/toon'
2
- import { stringify as yamlStringify } from 'yaml'
2
+
3
+ import * as Json from './internal/json.js'
4
+ import * as Yaml from './internal/yaml.js'
3
5
 
4
6
  /** Supported output formats. */
5
7
  export type Format = 'toon' | 'json' | 'yaml' | 'md' | 'jsonl'
@@ -16,13 +18,13 @@ export function format(value: unknown, fmt: Format = 'toon'): string {
16
18
  } catch {}
17
19
  }
18
20
  }
19
- return JSON.stringify(value, null, 2)
21
+ return Json.stringify(value, 2)
20
22
  }
21
- if (fmt === 'yaml') return yamlStringify(value)
23
+ if (fmt === 'yaml') return Yaml.loadSync().stringify(value)
22
24
  if (fmt === 'md') return formatMarkdown(value)
23
25
  if (fmt === 'jsonl') {
24
- if (Array.isArray(value)) return value.map((v) => JSON.stringify(v)).join('\n')
25
- return JSON.stringify(value)
26
+ if (Array.isArray(value)) return value.map((v) => Json.stringify(v)).join('\n')
27
+ return Json.stringify(value)
26
28
  }
27
29
  // toon (default)
28
30
  if (isScalar(value)) return String(value)
package/src/Help.test.ts CHANGED
@@ -411,4 +411,35 @@ describe('formatRoot', () => {
411
411
  --version Show version"
412
412
  `)
413
413
  })
414
+
415
+ test('formatCommand shows custom global options with deprecated flag', () => {
416
+ const result = Help.formatCommand('tool deploy', {
417
+ description: 'Deploy app',
418
+ globals: {
419
+ schema: z.object({
420
+ rpcUrl: z.string().optional().describe('RPC endpoint URL'),
421
+ oldRpc: z.string().optional().describe('Old RPC endpoint').meta({ deprecated: true }),
422
+ }),
423
+ alias: { rpcUrl: 'r' },
424
+ },
425
+ })
426
+ expect(result).toContain('Custom Global Options:')
427
+ expect(result).toContain('--rpc-url, -r <string>')
428
+ expect(result).toContain('RPC endpoint URL')
429
+ expect(result).toContain('[deprecated] Old RPC endpoint')
430
+ })
431
+
432
+ test('formatRoot shows custom global options', () => {
433
+ const result = Help.formatRoot('tool', {
434
+ globals: {
435
+ schema: z.object({
436
+ chain: z.string().default('mainnet').describe('Target chain'),
437
+ }),
438
+ },
439
+ commands: [{ name: 'deploy', description: 'Deploy' }],
440
+ })
441
+ expect(result).toContain('Custom Global Options:')
442
+ expect(result).toContain('--chain <string>')
443
+ expect(result).toContain('Target chain (default: mainnet)')
444
+ })
414
445
  })
package/src/Help.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  import { z } from 'zod'
2
2
 
3
+ import type { GlobalsDescriptor } from './Cli.js'
3
4
  import { builtinCommands } from './internal/command.js'
4
5
  import { toKebab } from './internal/helpers.js'
5
6
  import { defaultEnvSource } from './Parser.js'
6
7
 
7
8
  /** Formats help text for a router CLI or command group. */
8
9
  export function formatRoot(name: string, options: formatRoot.Options = {}): string {
9
- const { aliases, configFlag, description, version, commands = [], root = false } = options
10
+ const {
11
+ aliases,
12
+ configFlag,
13
+ description,
14
+ globals,
15
+ version,
16
+ commands = [],
17
+ root = false,
18
+ } = options
10
19
  const lines: string[] = []
11
20
 
12
21
  // Header
@@ -31,7 +40,7 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri
31
40
  }
32
41
  }
33
42
 
34
- lines.push(...globalOptionsLines(root, configFlag))
43
+ lines.push(...globalOptionsLines(root, configFlag, globals))
35
44
 
36
45
  return lines.join('\n')
37
46
  }
@@ -46,6 +55,8 @@ export declare namespace formatRoot {
46
55
  commands?: { name: string; description?: string | undefined }[] | undefined
47
56
  /** A short description of the CLI or group. */
48
57
  description?: string | undefined
58
+ /** Custom global options schema and alias map. */
59
+ globals?: GlobalsDescriptor | undefined
49
60
  /** Show root-level built-in commands and flags. */
50
61
  root?: boolean | undefined
51
62
  /** CLI version string. */
@@ -71,6 +82,8 @@ export declare namespace formatCommand {
71
82
  env?: z.ZodObject<any> | undefined
72
83
  /** Override environment variable source for "set:" display. Defaults to `process.env`. */
73
84
  envSource?: Record<string, string | undefined> | undefined
85
+ /** Custom global options schema and alias map. */
86
+ globals?: GlobalsDescriptor | undefined
74
87
  /** Formatted usage examples. */
75
88
  examples?: { command: string; description?: string }[] | undefined
76
89
  /** Plain text hint displayed after examples and before global options. */
@@ -102,6 +115,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
102
115
  aliases,
103
116
  configFlag,
104
117
  description,
118
+ globals,
105
119
  version,
106
120
  args,
107
121
  env,
@@ -206,7 +220,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
206
220
  }
207
221
  }
208
222
 
209
- if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag))
223
+ if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag, globals))
210
224
 
211
225
  // Environment Variables
212
226
  if (env) {
@@ -340,7 +354,11 @@ function extractDeprecated(schema: unknown): boolean | undefined {
340
354
  }
341
355
 
342
356
  /** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */
343
- function globalOptionsLines(root = false, configFlag?: string): string[] {
357
+ function globalOptionsLines(
358
+ root = false,
359
+ configFlag?: string,
360
+ globals?: GlobalsDescriptor,
361
+ ): string[] {
344
362
  const lines: string[] = []
345
363
 
346
364
  if (root) {
@@ -361,6 +379,26 @@ function globalOptionsLines(root = false, configFlag?: string): string[] {
361
379
  )
362
380
  }
363
381
 
382
+ if (globals) {
383
+ const entries = optionEntries(globals.schema, globals.alias)
384
+ if (entries.length > 0) {
385
+ const maxLen = Math.max(...entries.map((e) => e.flag.length))
386
+ lines.push(
387
+ '',
388
+ 'Custom Global Options:',
389
+ ...entries.map((entry) => {
390
+ const padding = ' '.repeat(maxLen - entry.flag.length)
391
+ const prefix = entry.deprecated ? '[deprecated] ' : ''
392
+ const desc =
393
+ entry.defaultValue !== undefined
394
+ ? `${prefix}${entry.description} (default: ${entry.defaultValue})`
395
+ : `${prefix}${entry.description}`
396
+ return ` ${entry.flag}${padding} ${desc}`
397
+ }),
398
+ )
399
+ }
400
+ }
401
+
364
402
  const flags = [
365
403
  ...(configFlag
366
404
  ? [{ flag: `--${configFlag} <path>`, desc: 'Load JSON option defaults from a file' }]
package/src/Mcp.test.ts CHANGED
@@ -165,6 +165,18 @@ describe('Mcp', () => {
165
165
  expect(res.result.content).toEqual([{ type: 'text', text: '{"result":"HELLO"}' }])
166
166
  })
167
167
 
168
+ test('tools/call validation error includes fieldErrors', async () => {
169
+ const tool = Mcp.collectTools(createTestCommands(), []).find((tool) => tool.name === 'echo')!
170
+ const result = await Mcp.callTool(tool, { message: 123 })
171
+ expect(result.isError).toBe(true)
172
+ const [content] = result.content
173
+ expect(content).toBeDefined()
174
+ expect(JSON.parse(content!.text)).toMatchObject({
175
+ code: 'VALIDATION_ERROR',
176
+ fieldErrors: [{ code: 'invalid_type', missing: false, path: 'message' }],
177
+ })
178
+ })
179
+
168
180
  test('tools/call with nested group command', async () => {
169
181
  const [, res] = await mcpSession(createTestCommands(), [
170
182
  { id: 1, method: 'initialize', params: initParams },
@@ -177,6 +189,106 @@ describe('Mcp', () => {
177
189
  expect(res.result.content).toEqual([{ type: 'text', text: '{"greeting":"hello world"}' }])
178
190
  })
179
191
 
192
+ test('tools/call serializes bigint values as strings', async () => {
193
+ const commands = new Map<string, any>()
194
+ commands.set('whois', {
195
+ description: 'Return ENS data',
196
+ output: z.object({ expiry: z.bigint() }),
197
+ run() {
198
+ return { expiry: 2461152330n }
199
+ },
200
+ })
201
+
202
+ const [, res] = await mcpSession(commands, [
203
+ { id: 1, method: 'initialize', params: initParams },
204
+ { id: 2, method: 'tools/call', params: { name: 'whois', arguments: {} } },
205
+ ])
206
+ expect(res.result.isError).toBeUndefined()
207
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"expiry":"2461152330"}' }])
208
+ expect(res.result.structuredContent).toEqual({ expiry: '2461152330' })
209
+ })
210
+
211
+ test('tools/call surfaces cta metadata without changing structured content', async () => {
212
+ const commands = new Map<string, any>()
213
+ commands.set('show', {
214
+ description: 'Show a record',
215
+ output: z.object({ id: z.string() }),
216
+ run(c: any) {
217
+ return c.ok(
218
+ { id: 'foo' },
219
+ {
220
+ cta: {
221
+ description: 'Next:',
222
+ commands: [{ command: 'list', description: 'List all' }],
223
+ },
224
+ },
225
+ )
226
+ },
227
+ })
228
+
229
+ const [, res] = await mcpSession(commands, [
230
+ { id: 1, method: 'initialize', params: initParams },
231
+ { id: 2, method: 'tools/call', params: { name: 'show', arguments: {} } },
232
+ ])
233
+
234
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"id":"foo"}' }])
235
+ expect(res.result.structuredContent).toEqual({ id: 'foo' })
236
+ expect(res.result._meta?.cta).toEqual({
237
+ description: 'Next:',
238
+ commands: [{ command: 'test-cli list', description: 'List all' }],
239
+ })
240
+ })
241
+
242
+ test('callTool serializes bigint values as strings', async () => {
243
+ const result = await Mcp.callTool(
244
+ {
245
+ name: 'whois',
246
+ inputSchema: { type: 'object', properties: {} },
247
+ outputSchema: {
248
+ type: 'object',
249
+ properties: { expiry: { type: 'string' } },
250
+ required: ['expiry'],
251
+ },
252
+ command: {
253
+ run() {
254
+ return { expiry: 2461152330n }
255
+ },
256
+ },
257
+ },
258
+ {},
259
+ )
260
+
261
+ expect(result.isError).toBeUndefined()
262
+ expect(result.content).toEqual([{ type: 'text', text: '{"expiry":"2461152330"}' }])
263
+ expect(result.structuredContent).toEqual({ expiry: '2461152330' })
264
+ })
265
+
266
+ test('callTool serializes streamed bigint chunks as strings', async () => {
267
+ const notifications: any[] = []
268
+ const result = await Mcp.callTool(
269
+ {
270
+ name: 'whois',
271
+ inputSchema: { type: 'object', properties: {} },
272
+ command: {
273
+ async *run() {
274
+ yield { expiry: 2461152330n }
275
+ },
276
+ },
277
+ },
278
+ {},
279
+ {
280
+ extra: { mcpReq: { _meta: { progressToken: 'tok-1' } } },
281
+ sendNotification: async (notification) => {
282
+ notifications.push(notification)
283
+ },
284
+ },
285
+ )
286
+
287
+ expect(result.isError).toBeUndefined()
288
+ expect(result.content).toEqual([{ type: 'text', text: '[{"expiry":"2461152330"}]' }])
289
+ expect(notifications[0].params.message).toBe('{"expiry":"2461152330"}')
290
+ })
291
+
180
292
  test('tools/call unknown tool returns error', async () => {
181
293
  const [, res] = await mcpSession(createTestCommands(), [
182
294
  { id: 1, method: 'initialize', params: initParams },
@@ -410,4 +522,85 @@ describe('Mcp', () => {
410
522
  expect(progress[0].params.progress).toBe(1)
411
523
  expect(progress[1].params.progress).toBe(2)
412
524
  })
525
+
526
+ test('serve options.instructions appears in initialize response', async () => {
527
+ const input = new PassThrough()
528
+ const output = new PassThrough()
529
+ const chunks: string[] = []
530
+ output.on('data', (chunk) => chunks.push(chunk.toString()))
531
+
532
+ const done = Mcp.serve('test-cli', '1.0.0', createTestCommands(), {
533
+ input,
534
+ output,
535
+ instructions: 'Use this CLI to run test commands.',
536
+ })
537
+
538
+ input.write(
539
+ `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams })}\n`,
540
+ )
541
+ await new Promise((r) => setTimeout(r, 20))
542
+ input.end()
543
+ await done
544
+
545
+ const [res] = chunks.map((c) => JSON.parse(c.trim()))
546
+ expect(res.result.instructions).toBe('Use this CLI to run test commands.')
547
+ })
548
+
549
+ test('command mcp.annotations appear in tools/list', async () => {
550
+ const commands = new Map<string, any>()
551
+ commands.set('read-data', {
552
+ description: 'Read some data',
553
+ mcp: { annotations: { readOnlyHint: true, idempotentHint: true } },
554
+ run: () => ({ data: 42 }),
555
+ })
556
+
557
+ const [, res] = await mcpSession(commands, [
558
+ { id: 1, method: 'initialize', params: initParams },
559
+ { id: 2, method: 'tools/list', params: {} },
560
+ ])
561
+ const tool = res.result.tools.find((t: any) => t.name === 'read-data')
562
+ expect(tool.annotations).toEqual({ readOnlyHint: true, idempotentHint: true })
563
+ })
564
+
565
+ test('command mcp.instructions appear in tools/list as _meta.instructions', async () => {
566
+ const commands = new Map<string, any>()
567
+ commands.set('guided', {
568
+ description: 'A guided command',
569
+ mcp: { instructions: 'Pass a valid JSON payload.' },
570
+ run: () => ({ ok: true }),
571
+ })
572
+
573
+ const [, res] = await mcpSession(commands, [
574
+ { id: 1, method: 'initialize', params: initParams },
575
+ { id: 2, method: 'tools/list', params: {} },
576
+ ])
577
+ const tool = res.result.tools.find((t: any) => t.name === 'guided')
578
+ expect(tool._meta?.instructions).toBe('Pass a valid JSON payload.')
579
+ })
580
+
581
+ test('collectTools extracts annotations and instructions from entry.mcp', () => {
582
+ const commands = new Map<string, any>()
583
+ commands.set('destroy', {
584
+ description: 'Destructive op',
585
+ mcp: {
586
+ annotations: { destructiveHint: true, openWorldHint: false },
587
+ instructions: 'Only call this in dry-run mode.',
588
+ },
589
+ run: () => null,
590
+ })
591
+
592
+ const tools = Mcp.collectTools(commands, [])
593
+ expect(tools).toHaveLength(1)
594
+ expect(tools[0]?.annotations).toEqual({ destructiveHint: true, openWorldHint: false })
595
+ expect(tools[0]?.instructions).toBe('Only call this in dry-run mode.')
596
+ })
597
+
598
+ test('collectTools omits annotations/instructions when not set', () => {
599
+ const commands = new Map<string, any>()
600
+ commands.set('plain', { description: 'No mcp opts', run: () => null })
601
+
602
+ const tools = Mcp.collectTools(commands, [])
603
+ expect(tools[0]?.annotations).toBeUndefined()
604
+ expect(tools[0]?.instructions).toBeUndefined()
605
+ })
413
606
  })