fix: wrap long-flag array values

This commit is contained in:
Peter Steinberger 2026-05-04 05:12:57 +01:00
parent 25e68730a9
commit 41fa8cb06e
No known key found for this signature in database
5 changed files with 83 additions and 6 deletions

View File

@ -7,7 +7,7 @@
- Return a non-zero exit code when MCP tool results are marked `isError`, and preserve that status through the forced-exit cleanup path. (PR #154 / issue #153, thanks @jlapenna)
- Give forced-exit cleanup a short stdout/stderr flush window so large JSON output is not truncated when `mcporter` is run from `child_process`. (PR #151 / issue #145, thanks @yuhp)
- Treat `key:=value` as a compatibility alias for `key=value`, avoiding malformed keys such as `price:`. (PR #150 / issue #100, thanks @solomonneas)
- Restore `mcporter call --key value` / `--key=value` tool arguments, including JSON array/object coercion, `--json -` stdin payloads, and kebab-case to camelCase field mapping. (Issues #119 and #126)
- Restore `mcporter call --key value` / `--key=value` tool arguments, including JSON array/object coercion, `--json -` stdin payloads, schema-aware bare string-to-array wrapping, and kebab-case to camelCase field mapping. (Issues #119 and #126)
- Quote generated `emit-ts` members for tool names that are not valid TypeScript identifiers. (PR #149 / issue #30, thanks @solomonneas)
### Config

View File

@ -70,6 +70,7 @@ Key details:
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
- `--args -` and `--json -` read a JSON object from stdin.
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
- `--no-coerce` disables all coercion for flag-style and positional values (`true`, `null`, and JSON-like values remain strings).

View File

@ -20,6 +20,7 @@ export interface CallArgsParseResult {
tool?: string;
args: Record<string, unknown>;
schemaStringCoercionCandidates?: Record<string, string>;
schemaArrayCoercionCandidates?: Record<string, string>;
positionalArgs?: unknown[];
tailLog: boolean;
output: OutputFormat;
@ -323,6 +324,9 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
if (context.state.coercionMode === 'default' && typeof value === 'number') {
context.result.schemaStringCoercionCandidates ??= {};
context.result.schemaStringCoercionCandidates[key] = rawValue;
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
context.result.schemaArrayCoercionCandidates ??= {};
context.result.schemaArrayCoercionCandidates[key] = rawValue;
}
context.result.args[key] = value;
return context.index + (eqIndex === -1 ? 2 : 1);

View File

@ -63,12 +63,13 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
const schemaAwareArgs = await enforceSchemaStringTypes(
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
runtime,
server,
tool,
hydratedArgs,
parsed.schemaStringCoercionCandidates,
parsed.schemaArrayCoercionCandidates,
timeoutMs
);
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs };
@ -272,15 +273,19 @@ function resolveCallTarget(
return { server, tool };
}
async function enforceSchemaStringTypes(
async function enforceSchemaAwareArgumentTypes(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,
tool: string,
args: Record<string, unknown>,
rawCandidates: Record<string, string> | undefined,
stringCandidates: Record<string, string> | undefined,
arrayCandidates: Record<string, string> | undefined,
timeoutMs: number
): Promise<Record<string, unknown>> {
if (!rawCandidates || Object.keys(rawCandidates).length === 0) {
if (
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
(!arrayCandidates || Object.keys(arrayCandidates).length === 0)
) {
return args;
}
@ -297,7 +302,7 @@ async function enforceSchemaStringTypes(
}
let corrected: Record<string, unknown> | undefined;
for (const [key, rawValue] of Object.entries(rawCandidates)) {
for (const [key, rawValue] of Object.entries(stringCandidates ?? {})) {
if (typeof args[key] !== 'number') {
continue;
}
@ -307,6 +312,17 @@ async function enforceSchemaStringTypes(
corrected ??= { ...args };
corrected[key] = rawValue;
}
for (const [key, rawValue] of Object.entries(arrayCandidates ?? {})) {
if (typeof args[key] !== 'string') {
continue;
}
const descriptor = schema.properties[key];
if (!schemaAllowsArray(descriptor) || schemaAllowsString(descriptor)) {
continue;
}
corrected ??= { ...args };
corrected[key] = [rawValue];
}
return corrected ?? args;
}
@ -331,6 +347,27 @@ function schemaAllowsString(descriptor: unknown): boolean {
return false;
}
function schemaAllowsArray(descriptor: unknown): boolean {
if (!descriptor || typeof descriptor !== 'object') {
return false;
}
const record = descriptor as Record<string, unknown>;
const type = record.type;
if (type === 'array') {
return true;
}
if (Array.isArray(type) && type.includes('array')) {
return true;
}
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
const variants = record[key];
if (Array.isArray(variants) && variants.some(schemaAllowsArray)) {
return true;
}
}
return false;
}
async function hydratePositionalArguments(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: string,

View File

@ -86,6 +86,41 @@ describe('CLI call execution behavior', () => {
logSpy.mockRestore();
});
it('wraps bare long-flag strings when the schema declares an array', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool, listTools } = createRuntimeStub({
email: [
{
name: 'send_email',
inputSchema: {
type: 'object',
properties: {
to: { type: 'array', items: { type: 'string' } },
subject: { type: 'string' },
},
required: ['to', 'subject'],
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['email.send_email', '--to', 'miguel@example.com', '--subject', 'Test']);
expect(callTool).toHaveBeenCalledWith(
'email',
'send_email',
expect.objectContaining({
args: {
to: ['miguel@example.com'],
subject: 'Test',
},
})
);
expect(listTools).toHaveBeenCalledWith('email', { autoAuthorize: true, includeSchema: true });
logSpy.mockRestore();
});
it('does not load schemas for numeric values supplied via --args JSON', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool, listTools } = createRuntimeStub({