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.
- package/dist/Cli.d.ts +69 -19
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +419 -73
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +2 -1
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +52 -13
- package/dist/Completions.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -5
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +5 -0
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +19 -5
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +21 -0
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +44 -13
- package/dist/Mcp.js.map +1 -1
- package/dist/Parser.d.ts +14 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +127 -1
- package/dist/Parser.js.map +1 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +5 -2
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.d.ts.map +1 -1
- package/dist/Skillgen.js +4 -38
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncMcp.d.ts.map +1 -1
- package/dist/SyncMcp.js +75 -8
- package/dist/SyncMcp.js.map +1 -1
- package/dist/SyncSkills.d.ts +9 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +13 -74
- package/dist/SyncSkills.js.map +1 -1
- package/dist/bin.d.ts +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/internal/command.d.ts +31 -19
- package/dist/internal/command.d.ts.map +1 -1
- package/dist/internal/command.js +10 -5
- package/dist/internal/command.js.map +1 -1
- package/dist/internal/cta.d.ts +22 -0
- package/dist/internal/cta.d.ts.map +1 -0
- package/dist/internal/cta.js +25 -0
- package/dist/internal/cta.js.map +1 -0
- package/dist/internal/json.d.ts +5 -0
- package/dist/internal/json.d.ts.map +1 -0
- package/dist/internal/json.js +13 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/internal/yaml.d.ts +12 -0
- package/dist/internal/yaml.d.ts.map +1 -0
- package/dist/internal/yaml.js +20 -0
- package/dist/internal/yaml.js.map +1 -0
- package/dist/middleware.d.ts +8 -4
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +1 -1
- package/src/Cli.test-d.ts +73 -0
- package/src/Cli.test.ts +1536 -48
- package/src/Cli.ts +606 -108
- package/src/Completions.test.ts +85 -0
- package/src/Completions.ts +48 -9
- package/src/Formatter.test.ts +17 -0
- package/src/Formatter.ts +7 -5
- package/src/Help.test.ts +32 -1
- package/src/Help.ts +42 -4
- package/src/Mcp.test.ts +289 -0
- package/src/Mcp.ts +68 -13
- package/src/Parser.test.ts +173 -0
- package/src/Parser.ts +132 -1
- package/src/Skill.test.ts +6 -0
- package/src/Skill.ts +8 -5
- package/src/Skillgen.test.ts +55 -6
- package/src/Skillgen.ts +9 -35
- package/src/SyncMcp.test.ts +89 -0
- package/src/SyncMcp.ts +72 -8
- package/src/SyncSkills.test.ts +78 -1
- package/src/SyncSkills.ts +20 -78
- package/src/bun-compile.test.ts +5 -0
- package/src/e2e.test.ts +166 -12
- package/src/internal/command.ts +12 -4
- package/src/internal/cta.ts +58 -0
- package/src/internal/json.ts +16 -0
- package/src/internal/yaml.ts +23 -0
- package/src/lazy-imports.test.ts +94 -0
- 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
|
-
|
|
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:
|
|
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:
|
|
147
|
+
return { content: [{ type: 'text', text: Json.stringify(chunks) }] }
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
if (!result.ok)
|
|
138
151
|
return {
|
|
139
|
-
content: [
|
|
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:
|
|
167
|
+
content: [{ type: 'text', text: Json.stringify(jsonData) }],
|
|
146
168
|
...(data !== null && tool.outputSchema
|
|
147
|
-
? { structuredContent:
|
|
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
|
-
...(
|
|
190
|
-
|
|
191
|
-
|
|
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,
|
package/src/Parser.test.ts
CHANGED
|
@@ -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
|
+
})
|