From ffc51ec8b355cd1f026ba03b33bb8ae069125e4c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 12:09:03 +0200 Subject: [PATCH] feat(site/src/pages/AgentsPage): show MCP tool inputs (#25568) Generic agent chat tool cards now render an `Input` section before the existing output viewer, so MCP and workspace MCP tools expose the arguments sent to the tool. Empty inputs stay hidden, model-intent wrappers are stripped before display, and the formatted input is the single source of truth for whether an input block renders. Refs https://linear.app/codercom/issue/CODAGT-260/show-mcp-tool-inputs-in-agent-chats > Mux worked on this on Mike's behalf. --- .../ChatElements/tools/Tool.stories.tsx | 47 +++++-- .../components/ChatElements/tools/Tool.tsx | 116 ++++++++++++------ .../ChatElements/tools/utils.test.ts | 61 +++++++++ .../components/ChatElements/tools/utils.ts | 60 +++++++-- 4 files changed, 231 insertions(+), 53 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx index 18144e7d84..218be52f2a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx @@ -17,6 +17,12 @@ const executeCommand = "git fetch origin"; const executeIntentCommand = "npm test"; const longExecuteCommand = "docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest ."; + +const getDiffsText = (element: HTMLElement) => + Array.from(element.querySelectorAll("diffs-container")) + .map((container) => container.shadowRoot?.textContent ?? "") + .join("\n"); + const meta: Meta = { title: "pages/AgentsPage/ChatElements/tools/Tool", component: Tool, @@ -1080,17 +1086,15 @@ export const MCPToolCompleted: Story = { expect(canvasElement.querySelector(".animate-spin")).toBeNull(); // Icon should still be monochrome when completed. expect(canvasElement.querySelector(".brightness-0")).not.toBeNull(); - // Result should be collapsed by default. const toggle = canvas.getByRole("button"); expect(toggle).toBeInTheDocument(); - // Expand to see result content. await userEvent.click(toggle); - // @pierre/diffs renders inside a Shadow DOM () - // so textContent on the host element can't see the content. - // Query into the shadow root to verify the JSON rendered. + expect(canvas.getByText("Input")).toBeVisible(); + expect(canvas.getByText("Output")).toBeVisible(); await waitFor(() => { - const shadow = canvasElement.querySelector("diffs-container")?.shadowRoot; - expect(shadow?.textContent).toContain("Fix auth flow"); + const diffsText = getDiffsText(canvasElement); + expect(diffsText).toContain("backend"); + expect(diffsText).toContain("Fix auth flow"); }); }, }; @@ -1124,8 +1128,12 @@ export const MCPToolNoResult: Story = { mcpServers: sampleMCPServers, }, play: async ({ canvasElement }) => { - // No toggle button when there is no result content. - expect(canvasElement.querySelector("button")).toBeNull(); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + expect(canvas.getByText("Input")).toBeVisible(); + await waitFor(() => { + expect(getDiffsText(canvasElement)).toContain("New issue"); + }); }, }; @@ -1196,6 +1204,27 @@ export const MCPToolNoServer: Story = { }, }; +export const WorkspaceMCPToolCompleted: Story = { + args: { + name: "workspace-mcp__echo", + status: "completed", + args: { message: "hello from workspace MCP" }, + result: { output: "hello from workspace MCP" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("workspace-mcp__echo")).toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button")); + expect(canvas.getByText("Input")).toBeVisible(); + expect(canvas.getByText("Output")).toBeVisible(); + await waitFor(() => { + const diffsText = getDiffsText(canvasElement); + expect(diffsText).toContain("message"); + expect(diffsText).toContain("hello from workspace MCP"); + }); + }, +}; + export const MCPToolModelIntentRunning: Story = { args: { name: "linear__list_issues", diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx index 4f0d0dc84e..e854718a7b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx @@ -51,6 +51,7 @@ import { DIFFS_FONT_STYLE, formatModelIntentLabel, formatResultOutput, + formatToolInput, getFileContentForViewer, getFileViewerOptions, getFileViewerOptionsNoHeader, @@ -822,6 +823,76 @@ const ComputerRenderer: FC = ({ ); }; +type ToolFileViewerProps = { + label?: string; + file: ComponentPropsWithRef["file"]; + options: ComponentPropsWithRef["options"]; +}; + +const ToolFileViewer: FC = ({ label, file, options }) => ( + <> + {label && ( +
+ {label} +
+ )} + + + + +); + +type GenericToolContentProps = { + toolInput: string | null; + fileContent: ReturnType; + fileContentOptions: ComponentPropsWithRef["options"]; + isDark: boolean; + resultOutput: string | null; +}; + +const GenericToolContent: FC = ({ + toolInput, + fileContent, + fileContentOptions, + isDark, + resultOutput, +}) => { + const output = fileContent + ? { + file: { name: fileContent.path, contents: fileContent.content }, + options: fileContentOptions, + } + : resultOutput + ? { + file: { name: "output.json", contents: resultOutput }, + options: getFileViewerOptionsNoHeader(isDark), + } + : undefined; + + return ( + <> + {toolInput && ( + + )} + {output && ( + + )} + + ); +}; + const GenericToolRenderer: FC = ({ name, status, @@ -834,6 +905,7 @@ const GenericToolRenderer: FC = ({ }) => { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; + const toolInput = formatToolInput(args); const resultOutput = formatResultOutput(result); const fileContent = getFileContentForViewer(name, args, result); const fileViewerOpts = getFileViewerOptions(isDark); @@ -850,7 +922,7 @@ const GenericToolRenderer: FC = ({ ? mcpServers?.find((s) => s.id === mcpServerConfigId) : undefined; - const hasContent = Boolean(fileContent || resultOutput); + const hasContent = Boolean(toolInput || fileContent || resultOutput); const isRunning = status === "running"; const rec = asRecord(result); const errorMessage = rec ? asString(rec.error || rec.message) : ""; @@ -891,41 +963,13 @@ const GenericToolRenderer: FC = ({ ); const toolContent = ( - <> - {fileContent ? ( - - - - ) : ( - resultOutput && ( - - - - ) - )} - + ); return ( diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts index dfc9d26089..2d7dd80228 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.test.ts @@ -10,6 +10,7 @@ import { formatModelIntentLabel, formatResultOutput, formatShellDurationMs, + formatToolInput, getDiffViewerOptions, getFileContentForViewer, getFileViewerOptions, @@ -306,6 +307,66 @@ describe("parseArgs", () => { }); }); +describe("formatToolInput", () => { + it("returns null for null, undefined, and empty inputs", () => { + expect(formatToolInput(null)).toBeNull(); + expect(formatToolInput(undefined)).toBeNull(); + expect(formatToolInput("")).toBeNull(); + expect(formatToolInput({})).toBeNull(); + expect(formatToolInput([])).toBeNull(); + expect(formatToolInput("{}")).toBeNull(); + expect(formatToolInput("[]")).toBeNull(); + expect(formatToolInput("null")).toBeNull(); + expect( + formatToolInput( + JSON.stringify({ + model_intent: "Reading backend issues", + properties: {}, + }), + ), + ).toBeNull(); + }); + + it("formats object input as pretty JSON", () => { + expect(formatToolInput({ project: "backend", limit: 2 })).toBe( + JSON.stringify({ project: "backend", limit: 2 }, null, 2), + ); + }); + + it("formats JSON string input as pretty JSON", () => { + expect(formatToolInput('{"project":"backend","limit":2}')).toBe( + JSON.stringify({ project: "backend", limit: 2 }, null, 2), + ); + }); + + it("unwraps model intent input wrappers", () => { + expect( + formatToolInput({ + model_intent: "Reading backend issues", + properties: { project: "backend" }, + }), + ).toBe(JSON.stringify({ project: "backend" }, null, 2)); + expect( + formatToolInput({ + model_intent: "Reading backend issues", + project: "backend", + }), + ).toBe(JSON.stringify({ project: "backend" }, null, 2)); + expect( + formatToolInput( + JSON.stringify({ + model_intent: "Reading backend issues", + properties: { project: "backend" }, + }), + ), + ).toBe(JSON.stringify({ project: "backend" }, null, 2)); + }); + + it("preserves non-JSON string input", () => { + expect(formatToolInput("search text")).toBe("search text"); + }); +}); + describe("formatResultOutput", () => { it("returns null for null and undefined", () => { expect(formatResultOutput(null)).toBeNull(); diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts index 2253c52d6c..8c74d5874a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts @@ -217,6 +217,57 @@ export const parseArgs = (args: unknown): Record | null => { return asRecord(args); }; +const getToolInputPayload = (args: unknown): unknown => { + const rec = asRecord(args); + if (!rec || typeof rec.model_intent !== "string") { + return args; + } + if ("properties" in rec) { + return rec.properties; + } + return Object.fromEntries( + Object.entries(rec).filter(([key]) => key !== "model_intent"), + ); +}; + +const isEmptyObjectOrArray = (value: unknown): boolean => { + if (Array.isArray(value)) { + return value.length === 0; + } + const rec = asRecord(value); + return rec ? Object.keys(rec).length === 0 : false; +}; + +const formatValue = (value: unknown): string => { + if (typeof value === "object") { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } + } + return String(value); +}; + +export const formatToolInput = (args: unknown): string | null => { + const input = getToolInputPayload(args); + if (input === undefined || input === null) { + return null; + } + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + try { + return formatToolInput(JSON.parse(trimmed)); + } catch { + return trimmed; + } + } + return isEmptyObjectOrArray(input) ? null : formatValue(input); +}; + export const formatResultOutput = (result: unknown): string | null => { if (result === undefined || result === null) { return null; @@ -238,14 +289,7 @@ export const formatResultOutput = (result: unknown): string | null => { return content; } } - if (typeof result === "object") { - try { - return JSON.stringify(result, null, 2); - } catch { - return String(result); - } - } - return String(result); + return formatValue(result); }; export const fileViewerCSS =