-
Notifications
You must be signed in to change notification settings - Fork 472
fix(mcp-cli): per-field stdin mode extracts JSON field instead of using raw JSON string as value #48100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(mcp-cli): per-field stdin mode extracts JSON field instead of using raw JSON string as value #48100
Changes from all commits
d00a9b2
42ddf06
c688ecc
20065dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import { | |
| shouldShowToolHelpForEmptyArgs, | ||
| showHelp, | ||
| showToolHelp, | ||
| tryExtractJsonFieldFromStdin, | ||
| unescapeCliStringArg, | ||
| writeStdoutAndFlush, | ||
| } from "./mcp_cli_bridge.cjs"; | ||
|
|
@@ -764,6 +765,162 @@ describe("mcp_cli_bridge.cjs", () => { | |
| }); | ||
| }); | ||
|
|
||
| describe("per-field stdin mode with JSON stdin — field extraction", () => { | ||
| it("extracts matching field from JSON stdin when --body . is used (space-separated)", () => { | ||
| // Root cause of gh-aw-workshop#2118: agent piped JSON payload and used --body . | ||
| // expecting the body field to be extracted, but the entire JSON string ended up as body. | ||
| const schemaProperties = { title: { type: "string" }, body: { type: "string" } }; | ||
| const stdinContent = '{"title":"Fix bug","body":"This PR fixes the issue."}'; | ||
|
|
||
| const { args } = parseToolArgs(["--title", "Fix bug", "--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ title: "Fix bug", body: "This PR fixes the issue." }); | ||
| }); | ||
|
|
||
| it("extracts matching field from JSON stdin when --body=. is used (equals-separated)", () => { | ||
| const schemaProperties = { title: { type: "string" }, body: { type: "string" } }; | ||
| const stdinContent = '{"title":"Fix bug","body":"Details here."}'; | ||
|
|
||
| const { args } = parseToolArgs(["--title", "Fix bug", "--body=."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ title: "Fix bug", body: "Details here." }); | ||
| }); | ||
|
|
||
| it("extracts both title and body when --title . --body . used with JSON stdin", () => { | ||
| const schemaProperties = { title: { type: "string" }, body: { type: "string" } }; | ||
| const stdinContent = '{"title":"Fix: Bug #123","body":"This PR fixes bug #123."}'; | ||
|
|
||
| const { args } = parseToolArgs(["--title", ".", "--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ title: "Fix: Bug #123", body: "This PR fixes bug #123." }); | ||
| }); | ||
|
|
||
| it("falls back to raw stdin when JSON does not contain the target key", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const stdinContent = '{"other_field":"value"}'; | ||
|
|
||
| const { args } = parseToolArgs(["--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| // No 'body' key in JSON → use raw stdin content | ||
| expect(args).toEqual({ body: stdinContent }); | ||
| }); | ||
|
|
||
| it("falls back to raw stdin when stdin is not a JSON object", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const stdinContent = "This is a long body from stdin."; | ||
|
|
||
| const { args } = parseToolArgs(["--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ body: stdinContent }); | ||
| }); | ||
|
|
||
| it("falls back to raw stdin when stdin is a JSON array", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const stdinContent = '["item1","item2"]'; | ||
|
|
||
| const { args } = parseToolArgs(["--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ body: stdinContent }); | ||
| }); | ||
|
|
||
| it("falls back to raw stdin when stdin is invalid JSON", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const stdinContent = '{"body": not-valid-json}'; | ||
|
|
||
| const { args } = parseToolArgs(["--body", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ body: stdinContent }); | ||
| }); | ||
|
|
||
| it("resolves dash/underscore aliased JSON key to canonical schema key", () => { | ||
| const schemaProperties = { issue_number: { type: "integer" } }; | ||
| const stdinContent = '{"issue-number":42}'; | ||
|
|
||
| const { args } = parseToolArgs(["--issue_number", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(args).toEqual({ issue_number: 42 }); | ||
| }); | ||
|
|
||
| it("preserves non-string JSON values extracted from stdin (e.g. boolean, number)", () => { | ||
| const schemaProperties = { draft: { type: "boolean" }, count: { type: "integer" } }; | ||
| const stdinContent = '{"draft":true,"count":5}'; | ||
|
|
||
| const { args: draftArgs } = parseToolArgs(["--draft", "."], schemaProperties, stdinContent); | ||
| const { args: countArgs } = parseToolArgs(["--count", "."], schemaProperties, stdinContent); | ||
|
|
||
| expect(draftArgs).toEqual({ draft: true }); | ||
| expect(countArgs).toEqual({ count: 5 }); | ||
| }); | ||
|
|
||
| it("handles multiline JSON payload with --body . correctly", () => { | ||
| const schemaProperties = { title: { type: "string" }, body: { type: "string" } }; | ||
| const stdinContent = `{ | ||
| "title": "Fix bug", | ||
| "body": "### Summary\\n\\nDetails here." | ||
| }`; | ||
|
|
||
| const { args } = parseToolArgs(["--title", "Fix bug", "--body", "."], schemaProperties, stdinContent); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Missing edge case: The 💡 Suggested testit('preserves null value extracted from JSON stdin', () => {
const schemaProperties = { body: { type: 'string' } };
const { args } = parseToolArgs(['--body', '.'], schemaProperties, '{"body":null}');
expect(args).toEqual({ body: null });
});This pins the @copilot please address this. |
||
|
|
||
| expect(args).toEqual({ title: "Fix bug", body: "### Summary\n\nDetails here." }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("tryExtractJsonFieldFromStdin", () => { | ||
| it("extracts a field value from a JSON object string", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const result = tryExtractJsonFieldFromStdin( | ||
| '{"title":"Fix","body":"PR description"}', | ||
| "body", | ||
| schemaProperties, | ||
| new Map([ | ||
| ["title", "title"], | ||
| ["body", "body"], | ||
| ]), | ||
| new Set() | ||
| ); | ||
| expect(result).toBe("PR description"); | ||
| }); | ||
|
|
||
| it("returns undefined when the key is absent from the JSON", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const result = tryExtractJsonFieldFromStdin('{"title":"Fix"}', "body", schemaProperties, new Map([["title", "title"]]), new Set()); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns undefined for non-object JSON (array)", () => { | ||
| const result = tryExtractJsonFieldFromStdin('["a","b"]', "body", {}, new Map(), new Set()); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns undefined for invalid JSON", () => { | ||
| const result = tryExtractJsonFieldFromStdin("{not valid}", "body", {}, new Map(), new Set()); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns undefined for plain text (not starting with {)", () => { | ||
| const result = tryExtractJsonFieldFromStdin("plain text", "body", {}, new Map(), new Set()); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("preserves null value extracted from JSON stdin (does not fall back to raw stdin)", () => { | ||
| const schemaProperties = { body: { type: "string" } }; | ||
| const result = tryExtractJsonFieldFromStdin('{"body":null}', "body", schemaProperties, new Map([["body", "body"]]), new Set()); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it("preserves false boolean extracted from JSON stdin", () => { | ||
| const schemaProperties = { draft: { type: "boolean" } }; | ||
| const result = tryExtractJsonFieldFromStdin('{"draft":false}', "draft", schemaProperties, new Map([["draft", "draft"]]), new Set()); | ||
| expect(result).toBe(false); | ||
| }); | ||
|
|
||
| it("preserves 0 number extracted from JSON stdin", () => { | ||
| const schemaProperties = { count: { type: "number" } }; | ||
| const result = tryExtractJsonFieldFromStdin('{"count":0}', "count", schemaProperties, new Map([["count", "count"]]), new Set()); | ||
| expect(result).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| describe("unescapeCliStringArg", () => { | ||
| it("converts \\n to an actual newline", () => { | ||
| expect(unescapeCliStringArg("Hello\\nWorld")).toBe("Hello\nWorld"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/codebase-design] The
startsWith('{')fast-path could reject valid JSON with leading whitespace.If
trimmedStdinis already trimmed (callers usestdinContent.trim()), this is fine. But the function's JSDoc says it accepts "pre-trimmed stdin" — consider asserting or documenting this contract more explicitly so future callers don't pass un-trimmed input and hit a silent miss.@copilot please address this.