incur 0.0.0 → 0.0.2

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/SKILL.md +664 -0
  4. package/dist/Cli.d.ts +255 -0
  5. package/dist/Cli.d.ts.map +1 -0
  6. package/dist/Cli.js +900 -0
  7. package/dist/Cli.js.map +1 -0
  8. package/dist/Errors.d.ts +92 -0
  9. package/dist/Errors.d.ts.map +1 -0
  10. package/dist/Errors.js +75 -0
  11. package/dist/Errors.js.map +1 -0
  12. package/dist/Formatter.d.ts +5 -0
  13. package/dist/Formatter.d.ts.map +1 -0
  14. package/dist/Formatter.js +91 -0
  15. package/dist/Formatter.js.map +1 -0
  16. package/dist/Help.d.ts +53 -0
  17. package/dist/Help.d.ts.map +1 -0
  18. package/dist/Help.js +231 -0
  19. package/dist/Help.js.map +1 -0
  20. package/dist/Mcp.d.ts +13 -0
  21. package/dist/Mcp.d.ts.map +1 -0
  22. package/dist/Mcp.js +140 -0
  23. package/dist/Mcp.js.map +1 -0
  24. package/dist/Parser.d.ts +24 -0
  25. package/dist/Parser.d.ts.map +1 -0
  26. package/dist/Parser.js +215 -0
  27. package/dist/Parser.js.map +1 -0
  28. package/dist/Register.d.ts +19 -0
  29. package/dist/Register.d.ts.map +1 -0
  30. package/dist/Register.js +2 -0
  31. package/dist/Register.js.map +1 -0
  32. package/dist/Schema.d.ts +4 -0
  33. package/dist/Schema.d.ts.map +1 -0
  34. package/dist/Schema.js +8 -0
  35. package/dist/Schema.js.map +1 -0
  36. package/dist/Skill.d.ts +29 -0
  37. package/dist/Skill.d.ts.map +1 -0
  38. package/dist/Skill.js +196 -0
  39. package/dist/Skill.js.map +1 -0
  40. package/dist/Skillgen.d.ts +3 -0
  41. package/dist/Skillgen.d.ts.map +1 -0
  42. package/dist/Skillgen.js +67 -0
  43. package/dist/Skillgen.js.map +1 -0
  44. package/dist/SyncMcp.d.ts +23 -0
  45. package/dist/SyncMcp.d.ts.map +1 -0
  46. package/dist/SyncMcp.js +100 -0
  47. package/dist/SyncMcp.js.map +1 -0
  48. package/dist/SyncSkills.d.ts +38 -0
  49. package/dist/SyncSkills.d.ts.map +1 -0
  50. package/dist/SyncSkills.js +163 -0
  51. package/dist/SyncSkills.js.map +1 -0
  52. package/dist/Typegen.d.ts +6 -0
  53. package/dist/Typegen.d.ts.map +1 -0
  54. package/dist/Typegen.js +92 -0
  55. package/dist/Typegen.js.map +1 -0
  56. package/dist/bin.d.ts +14 -0
  57. package/dist/bin.d.ts.map +1 -0
  58. package/dist/bin.js +30 -0
  59. package/dist/bin.js.map +1 -0
  60. package/dist/index.d.ts +15 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +14 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/internal/pm.d.ts +3 -0
  65. package/dist/internal/pm.d.ts.map +1 -0
  66. package/dist/internal/pm.js +11 -0
  67. package/dist/internal/pm.js.map +1 -0
  68. package/dist/internal/types.d.ts +11 -0
  69. package/dist/internal/types.d.ts.map +1 -0
  70. package/dist/internal/types.js +2 -0
  71. package/dist/internal/types.js.map +1 -0
  72. package/dist/internal/utils.d.ts +8 -0
  73. package/dist/internal/utils.d.ts.map +1 -0
  74. package/dist/internal/utils.js +51 -0
  75. package/dist/internal/utils.js.map +1 -0
  76. package/examples/npm/cli.ts +180 -0
  77. package/examples/npm/node_modules/.bin/incur.src +21 -0
  78. package/examples/npm/node_modules/.bin/tsx +21 -0
  79. package/examples/npm/package.json +14 -0
  80. package/examples/npm/tsconfig.json +9 -0
  81. package/examples/presto/cli.ts +246 -0
  82. package/examples/presto/node_modules/.bin/incur.src +21 -0
  83. package/examples/presto/node_modules/.bin/tsx +21 -0
  84. package/examples/presto/package.json +14 -0
  85. package/examples/presto/tsconfig.json +9 -0
  86. package/package.json +53 -2
  87. package/src/Cli.test-d.ts +135 -0
  88. package/src/Cli.test.ts +1373 -0
  89. package/src/Cli.ts +1470 -0
  90. package/src/Errors.test.ts +96 -0
  91. package/src/Errors.ts +139 -0
  92. package/src/Formatter.test.ts +245 -0
  93. package/src/Formatter.ts +106 -0
  94. package/src/Help.test.ts +124 -0
  95. package/src/Help.ts +302 -0
  96. package/src/Mcp.test.ts +254 -0
  97. package/src/Mcp.ts +195 -0
  98. package/src/Parser.test-d.ts +45 -0
  99. package/src/Parser.test.ts +118 -0
  100. package/src/Parser.ts +247 -0
  101. package/src/Register.ts +18 -0
  102. package/src/Schema.test.ts +125 -0
  103. package/src/Schema.ts +8 -0
  104. package/src/Skill.test.ts +293 -0
  105. package/src/Skill.ts +253 -0
  106. package/src/Skillgen.ts +66 -0
  107. package/src/SyncMcp.test.ts +75 -0
  108. package/src/SyncMcp.ts +132 -0
  109. package/src/SyncSkills.test.ts +92 -0
  110. package/src/SyncSkills.ts +205 -0
  111. package/src/Typegen.test.ts +150 -0
  112. package/src/Typegen.ts +107 -0
  113. package/src/bin.ts +33 -0
  114. package/src/e2e.test.ts +1710 -0
  115. package/src/index.ts +14 -0
  116. package/src/internal/pm.test.ts +38 -0
  117. package/src/internal/pm.ts +8 -0
  118. package/src/internal/types.ts +22 -0
  119. package/src/internal/utils.ts +50 -0
  120. package/src/tsconfig.json +8 -0
package/src/Help.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { z } from 'zod'
2
+
3
+ /** Formats help text for a router CLI or command group. */
4
+ export function formatRoot(name: string, options: formatRoot.Options = {}): string {
5
+ const { description, version, commands = [], root = false } = options
6
+ const lines: string[] = []
7
+
8
+ // Header
9
+ lines.push(description ? `${name} \u2014 ${description}` : name)
10
+ if (version) lines.push(`v${version}`)
11
+ lines.push('')
12
+
13
+ // Synopsis
14
+ lines.push(`Usage: ${name} <command>`)
15
+
16
+ // Commands
17
+ if (commands.length > 0) {
18
+ lines.push('')
19
+ lines.push('Commands:')
20
+ const maxLen = Math.max(...commands.map((c) => c.name.length))
21
+ for (const cmd of commands) {
22
+ if (cmd.description) {
23
+ const padding = ' '.repeat(maxLen - cmd.name.length)
24
+ lines.push(` ${cmd.name}${padding} ${cmd.description}`)
25
+ } else lines.push(` ${cmd.name}`)
26
+ }
27
+ }
28
+
29
+ lines.push(...globalOptionsLines(root))
30
+
31
+ return lines.join('\n')
32
+ }
33
+
34
+ export declare namespace formatRoot {
35
+ type Options = {
36
+ /** Commands to list. */
37
+ commands?: { name: string; description?: string | undefined }[] | undefined
38
+ /** A short description of the CLI or group. */
39
+ description?: string | undefined
40
+ /** Show root-level built-in commands and flags. */
41
+ root?: boolean | undefined
42
+ /** CLI version string. */
43
+ version?: string | undefined
44
+ }
45
+ }
46
+
47
+ export declare namespace formatCommand {
48
+ type Options = {
49
+ /** Map of option names to single-char aliases. */
50
+ alias?: Record<string, string> | undefined
51
+ /** Zod schema for positional arguments. */
52
+ args?: z.ZodObject<any> | undefined
53
+ /** A short description of what the command does. */
54
+ description?: string | undefined
55
+ /** Zod schema for environment variables. */
56
+ env?: z.ZodObject<any> | undefined
57
+ /** Formatted usage examples. */
58
+ examples?: { command: string; description?: string }[] | undefined
59
+ /** Plain text hint displayed after examples and before global options. */
60
+ hint?: string | undefined
61
+ /** Zod schema for named options/flags. */
62
+ options?: z.ZodObject<any> | undefined
63
+ /** Show root-level built-in commands and flags. */
64
+ root?: boolean | undefined
65
+ /** Alternative usage patterns. */
66
+ usage?:
67
+ | {
68
+ args?: Partial<Record<string, true>> | undefined
69
+ options?: Partial<Record<string, true>> | undefined
70
+ prefix?: string | undefined
71
+ suffix?: string | undefined
72
+ }[]
73
+ | undefined
74
+ /** CLI version string. */
75
+ version?: string | undefined
76
+ }
77
+ }
78
+
79
+ /** Formats help text for a leaf command. */
80
+ export function formatCommand(name: string, options: formatCommand.Options = {}): string {
81
+ const {
82
+ alias,
83
+ description,
84
+ version,
85
+ args,
86
+ env,
87
+ hint,
88
+ root = false,
89
+ options: opts,
90
+ examples,
91
+ } = options
92
+ const lines: string[] = []
93
+
94
+ // Header
95
+ lines.push(description ? `${name} \u2014 ${description}` : name)
96
+ if (version) lines.push(`v${version}`)
97
+ lines.push('')
98
+
99
+ // Synopsis
100
+ const { usage } = options
101
+ if (usage && usage.length > 0) {
102
+ const usageLines = usage.map((u) => {
103
+ const parts: string[] = []
104
+ if (u.prefix) parts.push(u.prefix)
105
+ parts.push(name)
106
+ if (u.args) for (const key of Object.keys(u.args)) parts.push(`<${key}>`)
107
+ if (u.options) for (const key of Object.keys(u.options)) parts.push(`--${key} <${key}>`)
108
+ if (u.suffix) parts.push(u.suffix)
109
+ return parts.join(' ')
110
+ })
111
+ const pad = ' '.repeat('Usage: '.length)
112
+ lines.push(`Usage: ${usageLines[0]}`)
113
+ for (const line of usageLines.slice(1)) lines.push(`${pad}${line}`)
114
+ } else {
115
+ const synopsis = buildSynopsis(name, args)
116
+ lines.push(`Usage: ${synopsis}${opts ? ' [options]' : ''}`)
117
+ }
118
+
119
+ // Arguments
120
+ if (args) {
121
+ const entries = argsEntries(args)
122
+ if (entries.length > 0) {
123
+ lines.push('')
124
+ lines.push('Arguments:')
125
+ const maxLen = Math.max(...entries.map((e) => e.name.length))
126
+ for (const entry of entries)
127
+ lines.push(` ${entry.name}${' '.repeat(maxLen - entry.name.length)} ${entry.description}`)
128
+ }
129
+ }
130
+
131
+ // Options
132
+ if (opts) {
133
+ const entries = optionEntries(opts, alias)
134
+ if (entries.length > 0) {
135
+ lines.push('')
136
+ lines.push('Options:')
137
+ const maxLen = Math.max(...entries.map((e) => e.flag.length))
138
+ for (const entry of entries) {
139
+ const padding = ' '.repeat(maxLen - entry.flag.length)
140
+ const desc =
141
+ entry.defaultValue !== undefined
142
+ ? `${entry.description} (default: ${entry.defaultValue})`
143
+ : entry.description
144
+ lines.push(` ${entry.flag}${padding} ${desc}`)
145
+ }
146
+ }
147
+ }
148
+
149
+ // Environment Variables
150
+ if (env) {
151
+ const entries = envEntries(env)
152
+ if (entries.length > 0) {
153
+ lines.push('')
154
+ lines.push('Environment Variables:')
155
+ const maxLen = Math.max(...entries.map((e) => e.name.length))
156
+ for (const entry of entries) {
157
+ const padding = ' '.repeat(maxLen - entry.name.length)
158
+ const desc =
159
+ entry.defaultValue !== undefined
160
+ ? `${entry.description} (default: ${entry.defaultValue})`
161
+ : entry.description
162
+ lines.push(` ${entry.name}${padding} ${desc}`)
163
+ }
164
+ }
165
+ }
166
+
167
+ // Examples
168
+ if (examples && examples.length > 0) {
169
+ lines.push('')
170
+ lines.push('Examples:')
171
+ const maxLen = Math.max(
172
+ ...examples.map((e) => (e.command ? `$ ${name} ${e.command}` : `$ ${name}`).length),
173
+ )
174
+ for (const ex of examples) {
175
+ const cmd = ex.command ? `$ ${name} ${ex.command}` : `$ ${name}`
176
+ if (ex.description)
177
+ lines.push(` ${cmd}${' '.repeat(maxLen - cmd.length)} ${ex.description}`)
178
+ else lines.push(` ${cmd}`)
179
+ }
180
+ }
181
+
182
+ // Hint
183
+ if (hint) {
184
+ lines.push('')
185
+ lines.push(hint)
186
+ }
187
+
188
+ lines.push(...globalOptionsLines(root))
189
+
190
+ return lines.join('\n')
191
+ }
192
+
193
+ /** Builds the synopsis string with `<required>` and `[optional]` placeholders. */
194
+ function buildSynopsis(name: string, args?: z.ZodObject<any>): string {
195
+ if (!args) return name
196
+ const parts = [name]
197
+ for (const [key, schema] of Object.entries(args.shape))
198
+ parts.push((schema as any).isOptional() ? `[${key}]` : `<${key}>`)
199
+ return parts.join(' ')
200
+ }
201
+
202
+ /** Extracts arg entries from a Zod object schema. */
203
+ function argsEntries(schema: z.ZodObject<any>) {
204
+ const entries: { name: string; description: string }[] = []
205
+ for (const [key, field] of Object.entries(schema.shape))
206
+ entries.push({ name: key, description: (field as any).description ?? '' })
207
+ return entries
208
+ }
209
+
210
+ /** Extracts env var entries from a Zod object schema. */
211
+ function envEntries(schema: z.ZodObject<any>) {
212
+ const entries: { name: string; description: string; defaultValue?: unknown }[] = []
213
+ for (const [key, field] of Object.entries(schema.shape)) {
214
+ const defaultValue = extractDefault(field)
215
+ entries.push({ name: key, description: (field as any).description ?? '', defaultValue })
216
+ }
217
+ return entries
218
+ }
219
+
220
+ /** Extracts option entries from a Zod object schema. */
221
+ function optionEntries(schema: z.ZodObject<any>, alias?: Record<string, string> | undefined) {
222
+ const entries: { flag: string; description: string; defaultValue?: unknown }[] = []
223
+ for (const [key, field] of Object.entries(schema.shape)) {
224
+ const type = resolveTypeName(field)
225
+ const short = alias?.[key]
226
+ const kebab = toKebab(key)
227
+ const flag = short ? `--${kebab}, -${short} <${type}>` : `--${kebab} <${type}>`
228
+ const defaultValue = extractDefault(field)
229
+ entries.push({ flag, description: (field as any).description ?? '', defaultValue })
230
+ }
231
+ return entries
232
+ }
233
+
234
+ /** Resolves a human-readable type name from a Zod schema. */
235
+ function resolveTypeName(schema: unknown): string {
236
+ const unwrapped = unwrap(schema)
237
+ if (unwrapped instanceof z.ZodString) return 'string'
238
+ if (unwrapped instanceof z.ZodNumber) return 'number'
239
+ if (unwrapped instanceof z.ZodBoolean) return 'boolean'
240
+ if (unwrapped instanceof z.ZodArray) return 'array'
241
+ return 'value'
242
+ }
243
+
244
+ /** Unwraps optional/default/nullable wrappers to get the inner type. */
245
+ function unwrap(schema: unknown): unknown {
246
+ if (schema instanceof z.ZodOptional) return unwrap(schema.unwrap())
247
+ if (schema instanceof z.ZodDefault) return unwrap(schema.removeDefault())
248
+ if (schema instanceof z.ZodNullable) return unwrap(schema.unwrap())
249
+ return schema
250
+ }
251
+
252
+ /** Extracts the default value from a Zod schema, if any. */
253
+ function extractDefault(schema: unknown): unknown {
254
+ if (schema instanceof z.ZodDefault) {
255
+ const raw = schema._def.defaultValue
256
+ const value = typeof raw === 'function' ? raw() : raw
257
+ if (Array.isArray(value) && value.length === 0) return undefined
258
+ return value
259
+ }
260
+ if (schema instanceof z.ZodOptional) return extractDefault(schema.unwrap())
261
+ return undefined
262
+ }
263
+
264
+ /** Converts a camelCase string to kebab-case. */
265
+ function toKebab(str: string): string {
266
+ return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
267
+ }
268
+
269
+ /** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */
270
+ function globalOptionsLines(root = false): string[] {
271
+ const lines: string[] = []
272
+
273
+ if (root) {
274
+ const builtins = [
275
+ { name: 'mcp add', desc: 'Register as an MCP server' },
276
+ { name: 'skills add', desc: 'Sync skill files to your agent' },
277
+ ]
278
+ const maxCmd = Math.max(...builtins.map((b) => b.name.length))
279
+ lines.push(
280
+ '',
281
+ 'Built-in Commands:',
282
+ ...builtins.map((b) => ` ${b.name}${' '.repeat(maxCmd - b.name.length)} ${b.desc}`),
283
+ )
284
+ }
285
+
286
+ const flags = [
287
+ { flag: '--format <toon|json|yaml|md|jsonl>', desc: 'Output format' },
288
+ { flag: '--help', desc: 'Show help' },
289
+ { flag: '--llms', desc: 'Print LLM-readable manifest' },
290
+ ...(root ? [{ flag: '--mcp', desc: 'Start as MCP stdio server' }] : []),
291
+ { flag: '--verbose', desc: 'Show full output envelope' },
292
+ ...(root ? [{ flag: '--version', desc: 'Show version' }] : []),
293
+ ]
294
+ const maxLen = Math.max(...flags.map((f) => f.flag.length))
295
+ lines.push(
296
+ '',
297
+ 'Global Options:',
298
+ ...flags.map((f) => ` ${f.flag}${' '.repeat(maxLen - f.flag.length)} ${f.desc}`),
299
+ )
300
+
301
+ return lines
302
+ }
@@ -0,0 +1,254 @@
1
+ import { Mcp, z } from 'incur'
2
+ import { PassThrough } from 'node:stream'
3
+
4
+ function createTestCommands() {
5
+ const commands = new Map<string, any>()
6
+
7
+ commands.set('ping', {
8
+ description: 'Health check',
9
+ run() {
10
+ return { pong: true }
11
+ },
12
+ })
13
+
14
+ commands.set('echo', {
15
+ description: 'Echo a message',
16
+ args: z.object({
17
+ message: z.string().describe('Message to echo'),
18
+ }),
19
+ options: z.object({
20
+ upper: z.boolean().default(false).describe('Uppercase output'),
21
+ }),
22
+ run({ args, options }: any) {
23
+ const msg = options.upper ? args.message.toUpperCase() : args.message
24
+ return { result: msg }
25
+ },
26
+ })
27
+
28
+ commands.set('greet', {
29
+ _group: true,
30
+ description: 'Greeting commands',
31
+ commands: new Map([
32
+ [
33
+ 'hello',
34
+ {
35
+ description: 'Say hello',
36
+ args: z.object({ name: z.string().describe('Name to greet') }),
37
+ run({ args }: any) {
38
+ return { greeting: `hello ${args.name}` }
39
+ },
40
+ },
41
+ ],
42
+ ]),
43
+ })
44
+
45
+ commands.set('fail', {
46
+ description: 'Always fails',
47
+ run({ error }: any) {
48
+ return error({ code: 'BOOM', message: 'it broke' })
49
+ },
50
+ })
51
+
52
+ commands.set('stream', {
53
+ description: 'Stream chunks',
54
+ async *run() {
55
+ yield { content: 'hello' }
56
+ yield { content: 'world' }
57
+ },
58
+ })
59
+
60
+ return commands
61
+ }
62
+
63
+ /** Standard initialize params for MCP protocol. */
64
+ const initParams = {
65
+ protocolVersion: '2024-11-05',
66
+ capabilities: {},
67
+ clientInfo: { name: 'test-client', version: '1.0.0' },
68
+ }
69
+
70
+ /** Sends JSON-RPC messages, ends the stream, waits for serve to finish, returns parsed responses. */
71
+ async function mcpSession(
72
+ commands: Map<string, any>,
73
+ messages: { method: string; params?: unknown; id?: number }[],
74
+ ) {
75
+ const input = new PassThrough()
76
+ const output = new PassThrough()
77
+ const chunks: string[] = []
78
+ output.on('data', (chunk) => chunks.push(chunk.toString()))
79
+
80
+ const done = Mcp.serve('test-cli', '1.0.0', commands, { input, output })
81
+
82
+ for (const msg of messages) {
83
+ const rpc = { jsonrpc: '2.0', ...msg }
84
+ input.write(`${JSON.stringify(rpc)}\n`)
85
+ }
86
+
87
+ // Give time for async processing then close
88
+ await new Promise((r) => setTimeout(r, 20))
89
+ input.end()
90
+ await done
91
+
92
+ return chunks.map((c) => JSON.parse(c.trim()))
93
+ }
94
+
95
+ describe('Mcp', () => {
96
+ test('initialize responds with server info', async () => {
97
+ const [res] = await mcpSession(createTestCommands(), [
98
+ { id: 1, method: 'initialize', params: initParams },
99
+ ])
100
+ expect(res.id).toBe(1)
101
+ expect(res.result.protocolVersion).toBe('2024-11-05')
102
+ expect(res.result.serverInfo).toEqual({ name: 'test-cli', version: '1.0.0' })
103
+ expect(res.result.capabilities.tools).toBeDefined()
104
+ })
105
+
106
+ test('tools/list returns all leaf commands as tools', async () => {
107
+ const [, res] = await mcpSession(createTestCommands(), [
108
+ { id: 1, method: 'initialize', params: initParams },
109
+ { id: 2, method: 'tools/list', params: {} },
110
+ ])
111
+ const names = res.result.tools.map((t: any) => t.name).sort()
112
+ expect(names).toEqual(['echo', 'fail', 'greet_hello', 'ping', 'stream'])
113
+
114
+ const echoTool = res.result.tools.find((t: any) => t.name === 'echo')
115
+ expect(echoTool.description).toBe('Echo a message')
116
+ expect(echoTool.inputSchema.properties.message).toBeDefined()
117
+ expect(echoTool.inputSchema.properties.upper).toBeDefined()
118
+ expect(echoTool.inputSchema.required).toContain('message')
119
+ })
120
+
121
+ test('notifications are ignored (no response)', async () => {
122
+ const responses = await mcpSession(createTestCommands(), [
123
+ { id: 1, method: 'initialize', params: initParams },
124
+ { method: 'notifications/initialized' },
125
+ { id: 2, method: 'ping' },
126
+ ])
127
+ expect(responses).toHaveLength(2)
128
+ expect(responses[0].id).toBe(1)
129
+ expect(responses[1].id).toBe(2)
130
+ })
131
+
132
+ test('tools/call executes simple command', async () => {
133
+ const [, res] = await mcpSession(createTestCommands(), [
134
+ { id: 1, method: 'initialize', params: initParams },
135
+ { id: 2, method: 'tools/call', params: { name: 'ping', arguments: {} } },
136
+ ])
137
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"pong":true}' }])
138
+ })
139
+
140
+ test('tools/call with args and options', async () => {
141
+ const [, res] = await mcpSession(createTestCommands(), [
142
+ { id: 1, method: 'initialize', params: initParams },
143
+ {
144
+ id: 2,
145
+ method: 'tools/call',
146
+ params: { name: 'echo', arguments: { message: 'hello', upper: true } },
147
+ },
148
+ ])
149
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"result":"HELLO"}' }])
150
+ })
151
+
152
+ test('tools/call with nested group command', async () => {
153
+ const [, res] = await mcpSession(createTestCommands(), [
154
+ { id: 1, method: 'initialize', params: initParams },
155
+ {
156
+ id: 2,
157
+ method: 'tools/call',
158
+ params: { name: 'greet_hello', arguments: { name: 'world' } },
159
+ },
160
+ ])
161
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"greeting":"hello world"}' }])
162
+ })
163
+
164
+ test('tools/call unknown tool returns error', async () => {
165
+ const [, res] = await mcpSession(createTestCommands(), [
166
+ { id: 1, method: 'initialize', params: initParams },
167
+ { id: 2, method: 'tools/call', params: { name: 'nope', arguments: {} } },
168
+ ])
169
+ // SDK returns a JSON-RPC error for unknown tools
170
+ const hasError = res.error?.message?.includes('nope') || res.result?.isError
171
+ expect(hasError).toBeTruthy()
172
+ })
173
+
174
+ test('tools/call with sentinel error result', async () => {
175
+ const [, res] = await mcpSession(createTestCommands(), [
176
+ { id: 1, method: 'initialize', params: initParams },
177
+ { id: 2, method: 'tools/call', params: { name: 'fail', arguments: {} } },
178
+ ])
179
+ expect(res.result.isError).toBe(true)
180
+ expect(res.result.content[0].text).toBe('it broke')
181
+ })
182
+
183
+ test('unknown method returns JSON-RPC error', async () => {
184
+ const [, res] = await mcpSession(createTestCommands(), [
185
+ { id: 1, method: 'initialize', params: initParams },
186
+ { id: 2, method: 'bogus/method', params: {} },
187
+ ])
188
+ // SDK returns either a JSON-RPC error or ignores unknown methods
189
+ expect(res.error ?? res.result).toBeDefined()
190
+ })
191
+
192
+ test('ping returns empty object', async () => {
193
+ const [, res] = await mcpSession(createTestCommands(), [
194
+ { id: 1, method: 'initialize', params: initParams },
195
+ { id: 2, method: 'ping' },
196
+ ])
197
+ expect(res.result).toEqual({})
198
+ })
199
+
200
+ test('options get defaults applied', async () => {
201
+ const [, res] = await mcpSession(createTestCommands(), [
202
+ { id: 1, method: 'initialize', params: initParams },
203
+ { id: 2, method: 'tools/call', params: { name: 'echo', arguments: { message: 'hi' } } },
204
+ ])
205
+ // upper defaults to false, so message stays lowercase
206
+ expect(res.result.content).toEqual([{ type: 'text', text: '{"result":"hi"}' }])
207
+ })
208
+
209
+ test('streaming command buffers chunks into array', async () => {
210
+ const [, res] = await mcpSession(createTestCommands(), [
211
+ { id: 1, method: 'initialize', params: initParams },
212
+ { id: 2, method: 'tools/call', params: { name: 'stream', arguments: {} } },
213
+ ])
214
+ expect(res.result.content).toEqual([
215
+ { type: 'text', text: '[{"content":"hello"},{"content":"world"}]' },
216
+ ])
217
+ })
218
+
219
+ test('streaming command sends progress notifications', async () => {
220
+ const input = new PassThrough()
221
+ const output = new PassThrough()
222
+ const chunks: any[] = []
223
+ output.on('data', (chunk) => chunks.push(JSON.parse(chunk.toString().trim())))
224
+
225
+ const done = Mcp.serve('test-cli', '1.0.0', createTestCommands(), { input, output })
226
+
227
+ // Initialize
228
+ input.write(
229
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initParams }) + '\n',
230
+ )
231
+ await new Promise((r) => setTimeout(r, 10))
232
+
233
+ // Call streaming tool with progressToken
234
+ input.write(
235
+ JSON.stringify({
236
+ jsonrpc: '2.0',
237
+ id: 2,
238
+ method: 'tools/call',
239
+ params: { name: 'stream', arguments: {}, _meta: { progressToken: 'tok-1' } },
240
+ }) + '\n',
241
+ )
242
+ await new Promise((r) => setTimeout(r, 50))
243
+ input.end()
244
+ await done
245
+
246
+ // Filter for progress notifications
247
+ const progress = chunks.filter((c) => c.method === 'notifications/progress')
248
+ expect(progress).toHaveLength(2)
249
+ expect(progress[0].params.message).toBe('{"content":"hello"}')
250
+ expect(progress[1].params.message).toBe('{"content":"world"}')
251
+ expect(progress[0].params.progress).toBe(1)
252
+ expect(progress[1].params.progress).toBe(2)
253
+ })
254
+ })