Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
50619cb48a fix: align --raw-strings and --no-coerce semantics (#59) (thanks @nobrainer-tech) 2026-03-02 21:35:33 +00:00
nobrainer-tech
3325d32d03 Add --raw-strings flag to prevent numeric string coercion
Values like phone numbers, PINs, or codes with leading zeros (e.g. 000123)
are auto-coerced to numbers by default. The --raw-strings flag (or --no-coerce
alias) preserves them as strings.

Note: Cannot use --raw as the flag name because it conflicts with the
existing --raw output format shortcut in output-format.ts.

Fixes: numeric strings being silently converted to numbers
2026-03-02 21:34:19 +00:00
4 changed files with 70 additions and 4 deletions

View File

@ -8,6 +8,7 @@
- Added optional `oauthScope`/`oauth_scope` config override as an escape hatch for providers that require explicit scopes.
- `createCallResult().json()` now collects all parseable JSON entries from MCP content arrays (single item stays backward-compatible), and raw inspect depth now stays readable without unbounded traversal. (PR #91, thanks @Blankdlh)
- OAuth wait/redirect now share one deferred to eliminate authorization race windows and preserve stable close-path errors, including wait-before-redirect and repeated-redirect flows. (PR #70, thanks @monotykamary)
- Added `--raw-strings` (numeric coercion off) and `--no-coerce` (all coercion off) for `mcporter call` argument parsing so IDs/codes can stay literal strings. (PR #59, thanks @nobrainer-tech)
### Tooling / Dependencies
- Updated dependencies to latest releases (including MCP SDK, Rolldown RC, Zod, Biome, Oxlint, Vitest, Bun types).

View File

@ -17,10 +17,14 @@ export interface CallArgsParseResult {
output: OutputFormat;
timeoutMs?: number;
ephemeral?: EphemeralServerSpec;
rawStrings?: boolean;
}
type CoercionMode = 'default' | 'raw-strings' | 'none';
export function parseCallArguments(args: string[]): CallArgsParseResult {
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
let coercionMode: CoercionMode = 'default';
const ephemeral = extractEphemeralServerFlags(args);
result.ephemeral = ephemeral;
result.output = consumeOutputFormat(args, {
@ -68,6 +72,18 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
index += 1;
continue;
}
if (token === '--raw-strings') {
coercionMode = 'raw-strings';
result.rawStrings = true;
index += 1;
continue;
}
if (token === '--no-coerce') {
coercionMode = 'none';
result.rawStrings = true;
index += 1;
continue;
}
if (token === '--args') {
const value = args[index + 1];
if (!value) {
@ -168,12 +184,12 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
}
const parsed = parseKeyValueToken(token, positional[index + 1]);
if (!parsed) {
trailingPositional.push(coerceValue(token));
trailingPositional.push(coerceValue(token, coercionMode));
index += 1;
continue;
}
index += parsed.consumed;
const value = coerceValue(parsed.rawValue);
const value = coerceValue(parsed.rawValue, coercionMode);
if (parsed.key === 'tool' && !result.tool) {
if (typeof value !== 'string') {
throw new Error("Argument 'tool' must be a string value.");
@ -273,18 +289,21 @@ function extractHttpCallExpression(raw: string): ReturnType<typeof parseCallExpr
};
}
function coerceValue(value: string): unknown {
function coerceValue(value: string, coercionMode: CoercionMode = 'default'): unknown {
const trimmed = value.trim();
if (trimmed === '') {
return '';
}
if (coercionMode === 'none') {
return trimmed;
}
if (trimmed === 'true' || trimmed === 'false') {
return trimmed === 'true';
}
if (trimmed === 'null' || trimmed === 'none') {
return null;
}
if (!Number.isNaN(Number(trimmed)) && trimmed === `${Number(trimmed)}`) {
if (coercionMode === 'default' && !Number.isNaN(Number(trimmed)) && trimmed === `${Number(trimmed)}`) {
return Number(trimmed);
}
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {

View File

@ -130,6 +130,8 @@ export function printCallHelp(): void {
'Runtime flags:',
' --timeout <ms> Override the call timeout.',
' --output text|markdown|json|raw Control formatting.',
' --raw-strings Keep numeric-looking argument values as strings.',
' --no-coerce Keep all key/value and positional arguments as raw strings.',
' --tail-log Stream returned log handles.',
'',
'Ad-hoc servers:',

View File

@ -49,4 +49,48 @@ describe('parseCallArguments', () => {
);
warnSpy.mockRestore();
});
it('coerces numeric strings to numbers by default', () => {
const parsed = parseCallArguments(['server.tool', 'code=123456']);
expect(parsed.args.code).toBe(123456);
expect(typeof parsed.args.code).toBe('number');
});
it('preserves numeric strings when --raw-strings flag is used', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'code=123456']);
expect(parsed.args.code).toBe('123456');
expect(typeof parsed.args.code).toBe('string');
expect(parsed.rawStrings).toBe(true);
});
it('preserves leading zeros when --raw-strings flag is used', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'pin=000123']);
expect(parsed.args.pin).toBe('000123');
expect(typeof parsed.args.pin).toBe('string');
});
it('still coerces booleans, nulls, and JSON with --raw-strings', () => {
const parsed = parseCallArguments(['--raw-strings', 'server.tool', 'enabled=true', 'value=null', 'meta={"a":1}']);
expect(parsed.args.enabled).toBe(true);
expect(parsed.args.value).toBeNull();
expect(parsed.args.meta).toEqual({ a: 1 });
});
it('keeps every value as a string when --no-coerce alias is used', () => {
const parsed = parseCallArguments([
'--no-coerce',
'server.tool',
'id=007',
'enabled=true',
'value=null',
'meta={"a":1}',
'123',
]);
expect(parsed.args.id).toBe('007');
expect(parsed.args.enabled).toBe('true');
expect(parsed.args.value).toBe('null');
expect(parsed.args.meta).toBe('{"a":1}');
expect(typeof parsed.args.id).toBe('string');
expect(parsed.positionalArgs).toEqual(['123']);
});
});