diff --git a/actions/setup/js/mcp_cli_bridge.cjs b/actions/setup/js/mcp_cli_bridge.cjs index 9857289f18..d45d124e4a 100644 --- a/actions/setup/js/mcp_cli_bridge.cjs +++ b/actions/setup/js/mcp_cli_bridge.cjs @@ -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; @@ -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} [schemaProperties] - Tool input schema properties * @param {string | null} [stdinContent] - Pre-read stdin content; substituted when value is '-' @@ -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; + } + 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); @@ -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)" : ""}`); @@ -1105,6 +1151,7 @@ module.exports = { renderProgressMessages, formatResponse, hasStdinPlaceholder, + hasStdinJsonPayload, readStdinSync, main, }; diff --git a/actions/setup/js/mcp_cli_bridge.test.cjs b/actions/setup/js/mcp_cli_bridge.test.cjs index 0bb488bed9..746026012f 100644 --- a/actions/setup/js/mcp_cli_bridge.test.cjs +++ b/actions/setup/js/mcp_cli_bridge.test.cjs @@ -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; @@ -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." }); + }); + }); }); diff --git a/actions/setup/md/mcp_cli_tools_prompt.md b/actions/setup/md/mcp_cli_tools_prompt.md index c40547eacd..61a37ba8e1 100644 --- a/actions/setup/md/mcp_cli_tools_prompt.md +++ b/actions/setup/md/mcp_cli_tools_prompt.md @@ -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 --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