diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be14b8..12e1592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [0.12.1] - Unreleased -- Nothing yet. +- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec) ## [0.12.0] - 2026-06-10 diff --git a/README.md b/README.md index a1c94cb..06be6ef 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q ```bash npx mcporter call chrome-devtools.take_snapshot npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")' +npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues @@ -163,6 +164,7 @@ Helpful flags: - `--save-images ` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged). - `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings. - `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion). +- `key=@path` / `--key @path` (on `mcporter call`) -- read a named argument as exact UTF-8 text from a file; use `@@` for a literal leading `@`. - `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`. - `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata. - `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy. diff --git a/docs/call-syntax.md b/docs/call-syntax.md index 6d31bf1..0e6c492 100644 --- a/docs/call-syntax.md +++ b/docs/call-syntax.md @@ -71,6 +71,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. +- Named flag-style values can read exact UTF-8 text from a file with `key=@path` or `--key @path`. Paths resolve from the current working directory, file contents remain strings without coercion, and `key=@@literal` produces the literal value `@literal`. Function-call strings such as `body: "@literal"` remain literal. - 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"`). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6e4de18..5a1c23e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -53,6 +53,7 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits - `--save-images ` – persist image content blocks to files under the specified directory. - `--raw-strings` – disable numeric coercion for flag-style and positional values. - `--no-coerce` – disable all flag-style/positional value coercion. + - `key=@path` / `--key @path` – read a named UTF-8 string argument from a file; prefix with `@@` for a literal leading `@`. - `--tail-log` – stream tail output when the tool returns log handles. - `--no-oauth` – never start an interactive OAuth flow; use cached tokens only while keeping eligible connections pooled. diff --git a/docs/tool-calling.md b/docs/tool-calling.md index 52940b5..3e7949b 100644 --- a/docs/tool-calling.md +++ b/docs/tool-calling.md @@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value - Use `--flag value` when you prefer long-form CLI syntax. - Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`. +- Use `body=@comment.md` (or `--body @comment.md`) to read an exact UTF-8 string from a file; use `body=@@literal` when the value itself starts with `@`. - `--args '{"title":"Bug"}'` still ingests JSON payloads directly. - Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`. diff --git a/src/cli/call-arguments.ts b/src/cli/call-arguments.ts index 6818124..dc17139 100644 --- a/src/cli/call-arguments.ts +++ b/src/cli/call-arguments.ts @@ -193,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul continue; } index += parsed.consumed; - const value = coerceValue(parsed.rawValue, state.coercionMode); + const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode); if (parsed.key === 'tool' && !result.tool) { if (typeof value !== 'string') { throw new Error("Argument 'tool' must be a string value."); @@ -210,7 +210,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul } if (state.coercionMode === 'default' && typeof value === 'number') { result.schemaStringCoercionCandidates ??= {}; - result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue; + result.schemaStringCoercionCandidates[parsed.key] = schemaValue; } result.args[parsed.key] = value; } @@ -327,18 +327,50 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number { eqIndex === -1 ? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`) : body.slice(eqIndex + 1); - const value = coerceValue(rawValue, context.state.coercionMode); + const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode); if (context.state.coercionMode === 'default' && typeof value === 'number') { context.result.schemaStringCoercionCandidates ??= {}; - context.result.schemaStringCoercionCandidates[key] = rawValue; + context.result.schemaStringCoercionCandidates[key] = schemaValue; } else if (context.state.coercionMode === 'default' && typeof value === 'string') { context.result.schemaArrayCoercionCandidates ??= {}; - context.result.schemaArrayCoercionCandidates[key] = rawValue; + context.result.schemaArrayCoercionCandidates[key] = schemaValue; } context.result.args[key] = value; return context.index + (eqIndex === -1 ? 2 : 1); } +function resolveNamedArgumentValue( + rawValue: string, + coercionMode: CoercionMode +): { value: unknown; schemaValue: string } { + if (rawValue.startsWith('@@')) { + const literal = rawValue.slice(1); + return { value: literal, schemaValue: literal }; + } + if (!rawValue.startsWith('@')) { + return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue }; + } + + const filePath = rawValue.slice(1); + if (!filePath) { + throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'."); + } + + let contents: Buffer; + try { + contents = fs.readFileSync(filePath); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`); + } + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(contents); + return { value: text, schemaValue: text }; + } catch { + throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`); + } +} + function normalizeLongFlagArgumentKey(rawKey: string): string { if (!rawKey || rawKey.startsWith('-')) { return ''; diff --git a/src/cli/call-help.ts b/src/cli/call-help.ts index cc292fc..d599849 100644 --- a/src/cli/call-help.ts +++ b/src/cli/call-help.ts @@ -1,5 +1,6 @@ export const CALL_HELP_ARGUMENT_LINES = [ ' key=value / key:value Flag-style named arguments.', + ' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.', ' function-call syntax \'server.tool(arg: "value", other: 1)\'.', ' --args Provide a JSON object payload.', ' positional values Accepted when schema order is known.', @@ -32,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [ export const CALL_HELP_EXAMPLE_LINES = [ ' mcporter call linear.list_issues team=ENG limit:5', + ' mcporter call linear.create_comment body=@comment.md', ' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"', ' mcporter call https://api.example.com/mcp.fetch url:https://example.com', ' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com', diff --git a/tests/call-arguments.test.ts b/tests/call-arguments.test.ts index 2ae2786..5faffbb 100644 --- a/tests/call-arguments.test.ts +++ b/tests/call-arguments.test.ts @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { parseCallArguments } from '../src/cli/call-arguments.js'; @@ -93,6 +95,50 @@ describe('parseCallArguments', () => { } }); + it('reads exact UTF-8 text from @path named argument values', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-')); + const payloadPath = path.join(tempDir, 'payload.txt'); + fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8'); + try { + const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]); + expect(parsed.args.body).toBe('first line\nsecond line\n'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('supports @path through generic long tool flags', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-')); + const payloadPath = path.join(tempDir, 'payload.txt'); + fs.writeFileSync(payloadPath, 'from file', 'utf8'); + try { + const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]); + expect(parsed.args.body).toBe('from file'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('uses @@ to preserve a literal leading @ without reading a file', () => { + const parsed = parseCallArguments(['server.tool', 'body=@@literal']); + expect(parsed.args.body).toBe('@literal'); + }); + + it('reports missing and non-UTF-8 argument files before transport', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-')); + const invalidPath = path.join(tempDir, 'invalid.bin'); + fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28])); + try { + expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow( + /Unable to read argument file/ + ); + expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/); + expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('throws when generic long flags are missing a value', () => { expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value."); }); diff --git a/tests/fixtures/stdio-memory-server.mjs b/tests/fixtures/stdio-memory-server.mjs index 67efaf4..e55eb74 100644 --- a/tests/fixtures/stdio-memory-server.mjs +++ b/tests/fixtures/stdio-memory-server.mjs @@ -32,6 +32,24 @@ server.registerTool( } ); +server.registerTool( + 'echo_text', + { + title: 'Echo Text', + description: 'Return the provided text unchanged', + inputSchema: { + text: z.string(), + }, + outputSchema: { + text: z.string(), + }, + }, + async ({ text }) => ({ + content: [{ type: 'text', text }], + structuredContent: { text }, + }) +); + server.registerTool( 'list_entities', { diff --git a/tests/stdio-servers.integration.test.ts b/tests/stdio-servers.integration.test.ts index 9b7b9c7..d47b651 100644 --- a/tests/stdio-servers.integration.test.ts +++ b/tests/stdio-servers.integration.test.ts @@ -117,4 +117,20 @@ describe('stdio MCP servers (filesystem + memory)', () => { }, 20000 ); + + memoryTest( + 'passes multiline @path argument values unchanged to a stdio MCP server', + async () => { + const payloadPath = path.join(tempDir, 'multiline.txt'); + const payload = 'first line\nsecond line\n'; + await fs.writeFile(payloadPath, payload, 'utf8'); + const callResult = await runCli( + ['call', 'memory-test.echo_text', '--output', 'json', `text=@${payloadPath}`], + configPath + ); + expect(callResult.stderr).toBe(''); + expect(JSON.parse(callResult.stdout)).toMatchObject({ text: payload }); + }, + 20000 + ); });