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.
This commit is contained in:
Michael Suchacz
2026-05-25 12:09:03 +02:00
committed by GitHub
parent 3bf5f80277
commit ffc51ec8b3
4 changed files with 231 additions and 53 deletions
@@ -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<typeof Tool> = {
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 (<diffs-container>)
// 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",
@@ -51,6 +51,7 @@ import {
DIFFS_FONT_STYLE,
formatModelIntentLabel,
formatResultOutput,
formatToolInput,
getFileContentForViewer,
getFileViewerOptions,
getFileViewerOptionsNoHeader,
@@ -822,6 +823,76 @@ const ComputerRenderer: FC<ToolRendererProps> = ({
);
};
type ToolFileViewerProps = {
label?: string;
file: ComponentPropsWithRef<typeof FileViewer>["file"];
options: ComponentPropsWithRef<typeof FileViewer>["options"];
};
const ToolFileViewer: FC<ToolFileViewerProps> = ({ label, file, options }) => (
<>
{label && (
<div className="mt-2 text-2xs font-medium text-content-secondary">
{label}
</div>
)}
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer file={file} options={options} style={DIFFS_FONT_STYLE} />
</ScrollArea>
</>
);
type GenericToolContentProps = {
toolInput: string | null;
fileContent: ReturnType<typeof getFileContentForViewer>;
fileContentOptions: ComponentPropsWithRef<typeof FileViewer>["options"];
isDark: boolean;
resultOutput: string | null;
};
const GenericToolContent: FC<GenericToolContentProps> = ({
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 && (
<ToolFileViewer
label="Input"
file={{ name: "input.json", contents: toolInput }}
options={getFileViewerOptionsNoHeader(isDark)}
/>
)}
{output && (
<ToolFileViewer
label={toolInput ? "Output" : undefined}
file={output.file}
options={output.options}
/>
)}
</>
);
};
const GenericToolRenderer: FC<ToolRendererProps> = ({
name,
status,
@@ -834,6 +905,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
}) => {
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<ToolRendererProps> = ({
? 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<ToolRendererProps> = ({
);
const toolContent = (
<>
{fileContent ? (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: fileContent.path,
contents: fileContent.content,
}}
options={fileContentOptions}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
) : (
resultOutput && (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<FileViewer
file={{
name: "output.json",
contents: resultOutput,
}}
options={getFileViewerOptionsNoHeader(isDark)}
style={DIFFS_FONT_STYLE}
/>
</ScrollArea>
)
)}
</>
<GenericToolContent
toolInput={toolInput}
fileContent={fileContent}
fileContentOptions={fileContentOptions}
isDark={isDark}
resultOutput={resultOutput}
/>
);
return (
@@ -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();
@@ -217,6 +217,57 @@ export const parseArgs = (args: unknown): Record<string, unknown> | 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 =