From b8b90f221f6ce3f905eddcee1402401959fb2298 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 21:17:01 +0000 Subject: [PATCH] fix: improve generated CLI object parsing (#114) (thanks @v2nic) --- CHANGELOG.md | 2 + src/cli/generate/tools.ts | 8 +++- src/cli/list-signature.ts | 3 ++ tests/generate-cli-helpers.test.ts | 12 +++++ tests/generate-cli.test.ts | 71 ++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d54e7f..09c835b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] ### CLI +- Generated CLIs now parse object-valued flags as JSON and render object placeholders/examples with JSON-shaped help text, so tools like Jira `fields` no longer receive raw strings. (PR #114, thanks @v2nic) +- Deduplicate concurrent keep-alive daemon restarts per server so repeated fatal errors only force-close the cached daemon transport once before retrying. (PR #125, thanks @zm2231) - Keep `mcporter call --output json` parseable by emitting valid JSON even when the command falls back to raw output. (PR #128, thanks @armanddp) - Ignore static `Authorization` headers once OAuth is active so imported editor configs cannot override fresh OAuth tokens. (PR #123, thanks @ahonn) - Preserve full JSON/error payloads when `data` is just one field instead of collapsing the response to `data` alone. (PR #106, thanks @AielloChan) diff --git a/src/cli/generate/tools.ts b/src/cli/generate/tools.ts index fdea830..0dea777 100644 --- a/src/cli/generate/tools.ts +++ b/src/cli/generate/tools.ts @@ -126,6 +126,8 @@ export function buildPlaceholder( return `<${normalized}:true|false>`; case 'array': return `<${normalized}:value1,value2>`; + case 'object': + return `<${normalized}:json>`; default: if (formatSlug) { return `<${normalized}:${formatSlug}>`; @@ -157,6 +159,8 @@ export function buildExampleValue( return 'true'; case 'array': return 'value1,value2'; + case 'object': + return '{"key":"value"}'; default: if (property.toLowerCase().includes('path')) { return '/path/to/file.md'; @@ -185,7 +189,7 @@ export function pickExampleLiteral(option: GeneratedOption): string | undefined } return `[${values.map((entry) => JSON.stringify(entry)).join(', ')}]`; } - if (option.type === 'number' || option.type === 'boolean') { + if (option.type === 'number' || option.type === 'boolean' || option.type === 'object') { return option.exampleValue; } try { @@ -207,6 +211,8 @@ export function buildFallbackLiteral(option: GeneratedOption): string { return 'true'; case 'array': return '["value1"]'; + case 'object': + return '{"key":"value"}'; default: { if (option.property.toLowerCase().includes('id')) { return JSON.stringify('example-id'); diff --git a/src/cli/list-signature.ts b/src/cli/list-signature.ts index 91bc8ab..a2173ab 100644 --- a/src/cli/list-signature.ts +++ b/src/cli/list-signature.ts @@ -192,6 +192,9 @@ function formatTypeAnnotation(option: GeneratedOption, colorize: boolean): strin case 'array': baseType = 'string[]'; break; + case 'object': + baseType = 'Record'; + break; case 'string': baseType = 'string'; break; diff --git a/tests/generate-cli-helpers.test.ts b/tests/generate-cli-helpers.test.ts index fc003b7..d9d8bb4 100644 --- a/tests/generate-cli-helpers.test.ts +++ b/tests/generate-cli-helpers.test.ts @@ -86,13 +86,16 @@ describe('generate helpers', () => { expect(buildPlaceholder('myPath', 'string', ['s1', 's2'])).toBe(''); expect(buildPlaceholder('createdAt', 'string', undefined, 'iso-8601')).toBe(''); + expect(buildPlaceholder('fields', 'object')).toBe(''); expect(buildExampleValue('itemId', 'string', undefined, undefined)).toBe('example-id'); expect(buildExampleValue('mode', 'string', ['fast'], undefined)).toBe('fast'); + expect(buildExampleValue('fields', 'object', undefined, undefined)).toBe('{"key":"value"}'); expect(inferType({ type: 'boolean' })).toBe('boolean'); expect(inferType({ type: 'integer' })).toBe('number'); expect(inferType({ type: ['null', 'integer'] })).toBe('number'); expect(inferType({ type: ['null', 'array'] })).toBe('array'); + expect(inferType({ type: 'object' })).toBe('object'); expect(inferType({})).toBe('unknown'); expect(inferArrayItemType({ type: 'array', items: { type: 'integer' } })).toBe('number'); @@ -158,5 +161,14 @@ describe('generate helpers', () => { placeholder: '', }) ).toBe('["value1"]'); + expect( + buildFallbackLiteral({ + type: 'object', + property: 'fields', + cliName: 'fields', + required: false, + placeholder: '', + }) + ).toBe('{"key":"value"}'); }); }); diff --git a/tests/generate-cli.test.ts b/tests/generate-cli.test.ts index 4a80c9e..4cb7632 100644 --- a/tests/generate-cli.test.ts +++ b/tests/generate-cli.test.ts @@ -96,6 +96,23 @@ if (process.platform !== 'win32') { structuredContent: { coords, flags, names, ids }, }) ); + server.registerTool( + 'object_tool', + { + title: 'Object Tool', + description: 'Tool with object input', + inputSchema: { + fields: z.record(z.string(), z.unknown()), + }, + outputSchema: { + fields: z.record(z.string(), z.unknown()), + }, + }, + async ({ fields }) => ({ + content: [{ type: 'text', text: JSON.stringify({ fields }) }], + structuredContent: { fields }, + }) + ); server.registerResource( 'greeting', new ResourceTemplate('greeting://{name}', { list: undefined }), @@ -448,6 +465,60 @@ describeGenerateCli('generateCli', () => { expect(parsed.ids).toEqual([1, 2, 3]); }, 30_000); + it('parses object option values as JSON in generated CLIs', async () => { + const inline = JSON.stringify({ + name: 'object-test', + description: 'Object parsing test', + command: baseUrl.toString(), + }); + const outputPath = path.join(tmpDir, 'object-test.ts'); + await fs.rm(outputPath, { force: true }); + + const { outputPath: renderedPath } = await generateCli({ + serverRef: inline, + outputPath, + runtime: 'node', + timeoutMs: 5_000, + }); + const content = await fs.readFile(renderedPath, 'utf8'); + + expect(content).toContain('--fields '); + expect(content).toContain('(value) => JSON.parse(value)'); + expect(content).toContain('"object-tool": "function object_tool(fields: Record): object;"'); + + const { execFile } = await import('node:child_process'); + const { stdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile( + 'pnpm', + [ + 'exec', + 'tsx', + renderedPath, + 'object-tool', + '--fields', + '{"summary":"Ship it","done":true}', + '--output', + 'json', + ], + execOptions(), + (error: import('node:child_process').ExecFileException | null, out: string, err: string) => { + if (error) { + reject(error); + return; + } + resolve({ stdout: out, stderr: err }); + } + ); + }); + + expect(JSON.parse(stdout)).toEqual({ + fields: { + done: true, + summary: 'Ship it', + }, + }); + }, 30_000); + it('accepts both kebab-case and underscore tool names for generated CLIs', async () => { const deepwikiRef = JSON.stringify({ name: 'deepwiki',