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
+ );
});