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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/SKILL.md +664 -0
- package/dist/Cli.d.ts +255 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +900 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Errors.d.ts +92 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +75 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Formatter.d.ts +5 -0
- package/dist/Formatter.d.ts.map +1 -0
- package/dist/Formatter.js +91 -0
- package/dist/Formatter.js.map +1 -0
- package/dist/Help.d.ts +53 -0
- package/dist/Help.d.ts.map +1 -0
- package/dist/Help.js +231 -0
- package/dist/Help.js.map +1 -0
- package/dist/Mcp.d.ts +13 -0
- package/dist/Mcp.d.ts.map +1 -0
- package/dist/Mcp.js +140 -0
- package/dist/Mcp.js.map +1 -0
- package/dist/Parser.d.ts +24 -0
- package/dist/Parser.d.ts.map +1 -0
- package/dist/Parser.js +215 -0
- package/dist/Parser.js.map +1 -0
- package/dist/Register.d.ts +19 -0
- package/dist/Register.d.ts.map +1 -0
- package/dist/Register.js +2 -0
- package/dist/Register.js.map +1 -0
- package/dist/Schema.d.ts +4 -0
- package/dist/Schema.d.ts.map +1 -0
- package/dist/Schema.js +8 -0
- package/dist/Schema.js.map +1 -0
- package/dist/Skill.d.ts +29 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +196 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Skillgen.d.ts +3 -0
- package/dist/Skillgen.d.ts.map +1 -0
- package/dist/Skillgen.js +67 -0
- package/dist/Skillgen.js.map +1 -0
- package/dist/SyncMcp.d.ts +23 -0
- package/dist/SyncMcp.d.ts.map +1 -0
- package/dist/SyncMcp.js +100 -0
- package/dist/SyncMcp.js.map +1 -0
- package/dist/SyncSkills.d.ts +38 -0
- package/dist/SyncSkills.d.ts.map +1 -0
- package/dist/SyncSkills.js +163 -0
- package/dist/SyncSkills.js.map +1 -0
- package/dist/Typegen.d.ts +6 -0
- package/dist/Typegen.d.ts.map +1 -0
- package/dist/Typegen.js +92 -0
- package/dist/Typegen.js.map +1 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +30 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/pm.d.ts +3 -0
- package/dist/internal/pm.d.ts.map +1 -0
- package/dist/internal/pm.js +11 -0
- package/dist/internal/pm.js.map +1 -0
- package/dist/internal/types.d.ts +11 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/internal/utils.d.ts +8 -0
- package/dist/internal/utils.d.ts.map +1 -0
- package/dist/internal/utils.js +51 -0
- package/dist/internal/utils.js.map +1 -0
- package/examples/npm/cli.ts +180 -0
- package/examples/npm/node_modules/.bin/incur.src +21 -0
- package/examples/npm/node_modules/.bin/tsx +21 -0
- package/examples/npm/package.json +14 -0
- package/examples/npm/tsconfig.json +9 -0
- package/examples/presto/cli.ts +246 -0
- package/examples/presto/node_modules/.bin/incur.src +21 -0
- package/examples/presto/node_modules/.bin/tsx +21 -0
- package/examples/presto/package.json +14 -0
- package/examples/presto/tsconfig.json +9 -0
- package/package.json +53 -2
- package/src/Cli.test-d.ts +135 -0
- package/src/Cli.test.ts +1373 -0
- package/src/Cli.ts +1470 -0
- package/src/Errors.test.ts +96 -0
- package/src/Errors.ts +139 -0
- package/src/Formatter.test.ts +245 -0
- package/src/Formatter.ts +106 -0
- package/src/Help.test.ts +124 -0
- package/src/Help.ts +302 -0
- package/src/Mcp.test.ts +254 -0
- package/src/Mcp.ts +195 -0
- package/src/Parser.test-d.ts +45 -0
- package/src/Parser.test.ts +118 -0
- package/src/Parser.ts +247 -0
- package/src/Register.ts +18 -0
- package/src/Schema.test.ts +125 -0
- package/src/Schema.ts +8 -0
- package/src/Skill.test.ts +293 -0
- package/src/Skill.ts +253 -0
- package/src/Skillgen.ts +66 -0
- package/src/SyncMcp.test.ts +75 -0
- package/src/SyncMcp.ts +132 -0
- package/src/SyncSkills.test.ts +92 -0
- package/src/SyncSkills.ts +205 -0
- package/src/Typegen.test.ts +150 -0
- package/src/Typegen.ts +107 -0
- package/src/bin.ts +33 -0
- package/src/e2e.test.ts +1710 -0
- package/src/index.ts +14 -0
- package/src/internal/pm.test.ts +38 -0
- package/src/internal/pm.ts +8 -0
- package/src/internal/types.ts +22 -0
- package/src/internal/utils.ts +50 -0
- 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
|
+
}
|
package/src/Mcp.test.ts
ADDED
|
@@ -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
|
+
})
|