fix: improve generated CLI object parsing (#114) (thanks @v2nic)

This commit is contained in:
Peter Steinberger 2026-03-28 21:17:01 +00:00
parent 855446ca39
commit b8b90f221f
No known key found for this signature in database
5 changed files with 95 additions and 1 deletions

View File

@ -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)

View File

@ -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');

View File

@ -192,6 +192,9 @@ function formatTypeAnnotation(option: GeneratedOption, colorize: boolean): strin
case 'array':
baseType = 'string[]';
break;
case 'object':
baseType = 'Record<string, unknown>';
break;
case 'string':
baseType = 'string';
break;

View File

@ -86,13 +86,16 @@ describe('generate helpers', () => {
expect(buildPlaceholder('myPath', 'string', ['s1', 's2'])).toBe('<my-path:s1|s2>');
expect(buildPlaceholder('createdAt', 'string', undefined, 'iso-8601')).toBe('<created-at:iso-8601>');
expect(buildPlaceholder('fields', 'object')).toBe('<fields:json>');
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: '<labels>',
})
).toBe('["value1"]');
expect(
buildFallbackLiteral({
type: 'object',
property: 'fields',
cliName: 'fields',
required: false,
placeholder: '<fields>',
})
).toBe('{"key":"value"}');
});
});

View File

@ -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 <fields:json>');
expect(content).toContain('(value) => JSON.parse(value)');
expect(content).toContain('"object-tool": "function object_tool(fields: Record<string, unknown>): 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',