feat: support file-backed call arguments (#213)
This commit is contained in:
parent
6f3f42ca42
commit
c1b58296db
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## [0.12.1] - Unreleased
|
## [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
|
## [0.12.0] - 2026-06-10
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
|
|||||||
```bash
|
```bash
|
||||||
npx mcporter call chrome-devtools.take_snapshot
|
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: "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 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 shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
||||||
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
||||||
@ -163,6 +164,7 @@ Helpful flags:
|
|||||||
- `--save-images <dir>` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged).
|
- `--save-images <dir>` (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.
|
- `--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).
|
- `--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 `--`.
|
- `--` (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.
|
- `--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.
|
- `--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.
|
||||||
|
|||||||
@ -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 `=`.
|
- `--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.
|
- 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.
|
- `--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.
|
- 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.
|
- 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"`).
|
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
|
||||||
|
|||||||
@ -53,6 +53,7 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
||||||
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
||||||
- `--no-coerce` – disable all flag-style/positional value coercion.
|
- `--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.
|
- `--tail-log` – stream tail output when the tool returns log handles.
|
||||||
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||||
tokens only while keeping eligible connections pooled.
|
tokens only while keeping eligible connections pooled.
|
||||||
|
|||||||
@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value
|
|||||||
|
|
||||||
- Use `--flag value` when you prefer long-form CLI syntax.
|
- 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`.
|
- 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.
|
- `--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 `--`.
|
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.
|
||||||
|
|
||||||
|
|||||||
@ -193,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
index += parsed.consumed;
|
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 (parsed.key === 'tool' && !result.tool) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new Error("Argument 'tool' must be a string value.");
|
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') {
|
if (state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
result.schemaStringCoercionCandidates ??= {};
|
result.schemaStringCoercionCandidates ??= {};
|
||||||
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
|
||||||
}
|
}
|
||||||
result.args[parsed.key] = value;
|
result.args[parsed.key] = value;
|
||||||
}
|
}
|
||||||
@ -327,18 +327,50 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
|||||||
eqIndex === -1
|
eqIndex === -1
|
||||||
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
||||||
: body.slice(eqIndex + 1);
|
: 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') {
|
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
context.result.schemaStringCoercionCandidates ??= {};
|
context.result.schemaStringCoercionCandidates ??= {};
|
||||||
context.result.schemaStringCoercionCandidates[key] = rawValue;
|
context.result.schemaStringCoercionCandidates[key] = schemaValue;
|
||||||
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
||||||
context.result.schemaArrayCoercionCandidates ??= {};
|
context.result.schemaArrayCoercionCandidates ??= {};
|
||||||
context.result.schemaArrayCoercionCandidates[key] = rawValue;
|
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
|
||||||
}
|
}
|
||||||
context.result.args[key] = value;
|
context.result.args[key] = value;
|
||||||
return context.index + (eqIndex === -1 ? 2 : 1);
|
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 {
|
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
||||||
if (!rawKey || rawKey.startsWith('-')) {
|
if (!rawKey || rawKey.startsWith('-')) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export const CALL_HELP_ARGUMENT_LINES = [
|
export const CALL_HELP_ARGUMENT_LINES = [
|
||||||
' key=value / key:value Flag-style named arguments.',
|
' 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)\'.',
|
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
||||||
' --args <json> Provide a JSON object payload.',
|
' --args <json> Provide a JSON object payload.',
|
||||||
' positional values Accepted when schema order is known.',
|
' 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 = [
|
export const CALL_HELP_EXAMPLE_LINES = [
|
||||||
' mcporter call linear.list_issues team=ENG limit:5',
|
' 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 "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
||||||
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
||||||
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { parseCallArguments } from '../src/cli/call-arguments.js';
|
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', () => {
|
it('throws when generic long flags are missing a value', () => {
|
||||||
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
|
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
|
||||||
});
|
});
|
||||||
|
|||||||
18
tests/fixtures/stdio-memory-server.mjs
vendored
18
tests/fixtures/stdio-memory-server.mjs
vendored
@ -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(
|
server.registerTool(
|
||||||
'list_entities',
|
'list_entities',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -117,4 +117,20 @@ describe('stdio MCP servers (filesystem + memory)', () => {
|
|||||||
},
|
},
|
||||||
20000
|
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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user