Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions actions/setup/js/mcp_cli_bridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,24 @@ function hasStdinPlaceholder(args) {
return false;
}

/**
* Check whether stdin should be read and parsed as a JSON payload for tool arguments.
* Returns true when the '.' sentinel is the only argument, or when no arguments are
* provided and stdin is not connected to a terminal (i.e. data is being piped).
*
* This enables agents to pipe complex multi-argument payloads as a single JSON object:
* printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment .
* printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment
*
* @param {string[]} args - User arguments after the tool name
* @returns {boolean}
*/
function hasStdinJsonPayload(args) {
if (args.length === 1 && args[0] === ".") return true;
if (args.length === 0 && !process.stdin.isTTY) return true;
return false;
}

/** Maximum bytes accepted from stdin to prevent memory exhaustion (10 MB) */
const STDIN_MAX_BYTES = 10 * 1024 * 1024;

Expand Down Expand Up @@ -508,6 +526,12 @@ function readStdinSync() {
* content is substituted in place of that value. This allows multiline
* strings to be piped safely: `printf 'line1\nline2' | cmd --body -`
*
* When `stdinContent` is provided and args is empty or `['.']`, the stdin
* content is parsed as a JSON object and its properties are used as tool
* arguments directly (JSON payload mode). This enables agents to pipe
* complex multi-argument payloads without shell quoting issues:
* printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment .
*
* @param {string[]} args - User arguments after the tool name
* @param {Record<string, {type?: string|string[]}>} [schemaProperties] - Tool input schema properties
* @param {string | null} [stdinContent] - Pre-read stdin content; substituted when value is '-'
Expand All @@ -520,6 +544,26 @@ function parseToolArgs(args, schemaProperties = {}, stdinContent = null) {
const hasSchemaProperties = Object.keys(schemaProperties).length > 0;
const { normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys } = buildNormalizedSchemaKeyMap(schemaProperties);

// JSON payload mode: when args is empty or ['.'] and stdinContent is available,
// parse stdin as a JSON object and use its properties directly as tool arguments.
if (stdinContent !== null && (args.length === 0 || (args.length === 1 && args[0] === "."))) {
const trimmed = stdinContent.trim();
if (trimmed) {
try {
const parsed = JSON.parse(trimmed);
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [key, value] of Object.entries(parsed)) {
const canonicalKey = resolveSchemaPropertyKey(key, schemaProperties, normalizedSchemaKeyMap, ambiguousNormalizedSchemaKeys);
result[canonicalKey] = value;
}
Comment on lines +554 to +558
return { args: result, json: false };
}
} catch {
// Not valid JSON; fall through to normal flag-based argument parsing.
}
}
}

for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) {
const raw = args[i].slice(2);
Expand Down Expand Up @@ -1042,10 +1086,12 @@ async function main() {
const matchedTool = tools.find(tool => tool && typeof tool === "object" && tool.name === toolName);
const schemaProperties = matchedTool && matchedTool.inputSchema && matchedTool.inputSchema.properties ? matchedTool.inputSchema.properties : {};

// Pre-read stdin once when any argument uses '-' as a stdin placeholder.
// Pre-read stdin once when any argument uses '-' as a stdin placeholder, or when
// the JSON payload mode is triggered ('.' sentinel or no args with piped stdin).
// This avoids shell escaping issues with multiline strings:
// printf 'line1\nline2' | safeoutputs add_comment --body -
const stdinContent = hasStdinPlaceholder(toolUserArgs) ? readStdinSync() : null;
// printf '{"issue_number":42,"body":"hello"}' | safeoutputs add_comment .
const stdinContent = hasStdinPlaceholder(toolUserArgs) || hasStdinJsonPayload(toolUserArgs) ? readStdinSync() : null;
const { args: toolArgs, json: jsonOutput } = parseToolArgs(toolUserArgs, schemaProperties, stdinContent);

core.info(`[${serverName}] Calling tool '${toolName}' with args: ${JSON.stringify(toolArgs)}${jsonOutput ? " (--json)" : ""}`);
Expand Down Expand Up @@ -1105,6 +1151,7 @@ module.exports = {
renderProgressMessages,
formatResponse,
hasStdinPlaceholder,
hasStdinJsonPayload,
readStdinSync,
main,
};
122 changes: 121 additions & 1 deletion actions/setup/js/mcp_cli_bridge.test.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { formatResponse, hasStdinPlaceholder, parseToolArgs, readStdinSync } from "./mcp_cli_bridge.cjs";
import { formatResponse, hasStdinPlaceholder, hasStdinJsonPayload, parseToolArgs, readStdinSync } from "./mcp_cli_bridge.cjs";

describe("mcp_cli_bridge.cjs", () => {
let originalCore;
Expand Down Expand Up @@ -338,4 +338,124 @@ describe("mcp_cli_bridge.cjs", () => {
}
});
});

describe("stdin JSON payload support", () => {
it("returns true for '.' sentinel", () => {
expect(hasStdinJsonPayload(["."])).toBe(true);
});

it("returns true for empty args when stdin is not a TTY", () => {
const origIsTTY = process.stdin.isTTY;
process.stdin.isTTY = undefined;
try {
expect(hasStdinJsonPayload([])).toBe(true);
} finally {
process.stdin.isTTY = origIsTTY;
}
});

it("returns false for empty args when stdin is a TTY", () => {
const origIsTTY = process.stdin.isTTY;
// @ts-ignore
process.stdin.isTTY = true;
try {
expect(hasStdinJsonPayload([])).toBe(false);
} finally {
process.stdin.isTTY = origIsTTY;
}
});

it("returns false when args contain flags", () => {
expect(hasStdinJsonPayload(["--body", "hello"])).toBe(false);
});

it("returns false when args has more than just '.'", () => {
expect(hasStdinJsonPayload([".", "--extra", "value"])).toBe(false);
});

it("parses stdin JSON object when '.' sentinel is used", () => {
const schemaProperties = {
issue_number: { type: "integer" },
body: { type: "string" },
};
const stdinContent = '{"issue_number": 42, "body": "hello world"}';

const { args } = parseToolArgs(["."], schemaProperties, stdinContent);

expect(args).toEqual({ issue_number: 42, body: "hello world" });
});

it("parses stdin JSON object when no args and stdinContent is provided", () => {
const schemaProperties = {
issue_number: { type: "integer" },
body: { type: "string" },
};
const stdinContent = '{"issue_number": 7, "body": "test body"}';

const { args } = parseToolArgs([], schemaProperties, stdinContent);

expect(args).toEqual({ issue_number: 7, body: "test body" });
});

it("preserves types from JSON payload without coercion", () => {
const schemaProperties = {
count: { type: "integer" },
enabled: { type: "boolean" },
tags: { type: "array" },
};
const stdinContent = '{"count": 5, "enabled": true, "tags": ["a", "b"]}';

const { args } = parseToolArgs(["."], schemaProperties, stdinContent);

expect(args).toEqual({ count: 5, enabled: true, tags: ["a", "b"] });
});

it("normalizes dashed JSON keys to schema underscore keys", () => {
const schemaProperties = {
issue_number: { type: "integer" },
};
const stdinContent = '{"issue-number": 99}';

const { args } = parseToolArgs(["."], schemaProperties, stdinContent);

expect(args).toEqual({ issue_number: 99 });
});

it("falls through to empty args when stdinContent is null and sentinel is used", () => {
const { args } = parseToolArgs(["."], {}, null);

expect(args).toEqual({});
});

it("falls through to empty args when stdinContent is empty string", () => {
const { args } = parseToolArgs(["."], {}, "");

expect(args).toEqual({});
});

it("falls through to normal parsing when stdinContent is not valid JSON", () => {
const schemaProperties = { body: { type: "string" } };

const { args } = parseToolArgs(["."], schemaProperties, "not json at all");

expect(args).toEqual({});
});

it("falls through when JSON is an array rather than an object", () => {
const { args } = parseToolArgs(["."], {}, '["a","b","c"]');

expect(args).toEqual({});
});

it("handles multiline JSON payload", () => {
const schemaProperties = { body: { type: "string" } };
const stdinContent = `{
"body": "### Title\\n\\nLine one.\\n\\nLine two."
}`;

const { args } = parseToolArgs(["."], schemaProperties, stdinContent);

expect(args).toEqual({ body: "### Title\n\nLine one.\n\nLine two." });
});
});
});
44 changes: 36 additions & 8 deletions actions/setup/md/mcp_cli_tools_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,27 +44,55 @@ mcpscripts --help # list all script tools
mcpscripts mcpscripts-gh --args "pr list --repo owner/repo --limit 5"
```

### Multiline String Arguments (stdin piping)
### Multiline and Multi-Argument Payloads (JSON stdin)

For parameters that contain multiline content (such as `--body` in `add_comment`), use `-` as the value and pipe the content via stdin. This avoids shell quoting and escaping issues:
**Preferred approach for any tool call with multiple or complex arguments**: pipe a JSON object to the CLI using `.` as the sentinel. The bridge parses stdin as the argument object, preserving all native types (numbers, booleans, arrays) without shell-quoting issues.

```bash
# Full argument payload as JSON — preferred for multi-argument calls
printf '{"issue_number":42,"body":"### Title\n\nBody paragraph one.\n\nBody paragraph two."}' \
| safeoutputs add_comment .

# Works with any tool — just match the parameter names from <server> <tool> --help
printf '{"title":"Fix: something","body":"Details here","labels":["bug","priority-high"]}' \
| safeoutputs create_issue .

# Pipe from a file
cat payload.json | safeoutputs add_comment .
```

> **Why prefer JSON payload mode?**
> - Single pipe operation for any number of arguments — no repeated `--key value` flags
> - Native types (integers, booleans, arrays) are preserved exactly as specified
> - No shell quoting or escaping needed for newlines, quotes, or special characters
> - Agents can construct the payload as a structured object before emitting the command

Key normalisation rules apply: parameter names with hyphens or underscores are interchangeable (e.g. `issue-number` and `issue_number` both work).

### Single-Parameter stdin Substitution

For the case where only **one** parameter needs multiline content, use `-` as its value:

```bash
# Write multiline content to a file and pipe it
cat body.txt | safeoutputs add_comment --body -
cat body.txt | safeoutputs add_comment --issue_number 42 --body -

# Or use a here-doc / printf for inline multiline content
printf '### Title\n\nBody paragraph one.\n\nBody paragraph two.' | safeoutputs add_comment --body -
# Or use printf for inline multiline content
printf '### Title\n\nBody paragraph one.\n\nBody paragraph two.' \
| safeoutputs add_comment --issue_number 42 --body -

# Works with --key=- form too
printf 'multiline\ncontent' | safeoutputs add_comment --body=-
printf 'multiline\ncontent' | safeoutputs add_comment --issue_number 42 --body=-
```

> **Important**: Always use stdin piping (`--body -`) instead of command substitution (`--body "$(cat file)"`) when the content contains newlines. Command substitution can strip trailing newlines and cause other quoting problems.

### Notes

- All parameters are passed as `--name value` pairs; boolean flags can be set with `--flag` (no value) to mean `true`
- Use `-` as a value to read that parameter from stdin (useful for multiline content)
- **Prefer JSON payload mode** (`printf '{...}' | server tool .`) for any call with multiple arguments or complex values
- All parameters can also be passed as `--name value` pairs; boolean flags can be set with `--flag` (no value) to mean `true`
- Use `.` as the only argument to parse stdin as a JSON object (all parameters supplied at once)
- Use `-` as a single value to read one parameter from stdin (single-field substitution)
- Output is printed to stdout; errors are printed to stderr with a non-zero exit code
- Run the CLI commands inside a `bash` tool call — they are shell executables, not MCP tools
- These CLI commands are read-only and cannot be modified by the agent
Expand Down