Skip to content
Open
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
39 changes: 39 additions & 0 deletions client/src/components/ToolResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,37 @@ interface ToolResultsProps {
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
isPollingTask?: boolean;
/**
* Wall-clock time the last `callTool` invocation took, in milliseconds.
* `undefined` means no measurement is available (e.g. legacy result path).
*/
durationMs?: number | null;
Comment on lines +16 to +20
}

// Format a millisecond duration for display in the tool result header.
// - < 1ms -> "<1 ms"
// - < 1s -> "123 ms"
// - < 1min -> "1.23 s"
// - >= 1m -> "1m 23.4s"
const formatCallDuration = (durationMs: number): string => {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "";
}
if (durationMs < 1) {
return "<1 ms";
}
if (durationMs < 1000) {
return `${Math.round(durationMs)} ms`;
}
if (durationMs < 60_000) {
return `${(durationMs / 1000).toFixed(2)} s`;
}
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = (durationMs - minutes * 60_000) / 1000;
return `${minutes}m ${seconds.toFixed(1)}s`;
Comment on lines +38 to +44
};

const checkContentCompatibility = (
structuredContent: unknown,
unstructuredContent: Array<{
Expand Down Expand Up @@ -65,6 +94,7 @@ const ToolResults = ({
resourceContent,
onReadResource,
isPollingTask,
durationMs,
}: ToolResultsProps) => {
if (!toolResult) return null;

Expand Down Expand Up @@ -141,6 +171,15 @@ const ToolResults = ({
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
{typeof durationMs === "number" && (
<span
className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400"
data-testid="tool-call-duration"
title="Time taken to run the tool (round-trip)"
>
({formatCallDuration(durationMs)})
</span>
)}
Comment on lines +174 to +182
</h4>
{structuredResult.structuredContent && (
<div className="mb-4">
Expand Down
27 changes: 21 additions & 6 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ const ToolsTab = ({
{ id: string; key: string; value: string }[]
>([]);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const [lastCallDurationMs, setLastCallDurationMs] = useState<number | null>(
null,
);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const { toast } = useToast();
const { copied, setCopied } = useCopy();
Expand Down Expand Up @@ -249,6 +252,10 @@ const ToolsTab = ({
// Reset validation errors when switching tools
setHasValidationErrors(false);

// Reset the recorded duration so the previous tool's timing doesn't
// appear next to the new tool's result.
setLastCallDurationMs(null);

// Clear form refs for the previous tool
formRefs.current = {};
}, [selectedTool, serverSupportsTaskRequests]);
Expand Down Expand Up @@ -824,12 +831,19 @@ const ToolsTab = ({
}
return acc;
}, {});
await callTool(
selectedTool.name,
params,
Object.keys(metadata).length ? metadata : undefined,
runAsTask,
);
// Measure the call duration so the user can see how
// long the tool took to respond (issue #1284).
const startTime = performance.now();
try {
await callTool(
selectedTool.name,
params,
Object.keys(metadata).length ? metadata : undefined,
runAsTask,
);
} finally {
setLastCallDurationMs(performance.now() - startTime);
}
} finally {
setIsToolRunning(false);
}
Expand Down Expand Up @@ -886,6 +900,7 @@ const ToolsTab = ({
resourceContent={resourceContent}
onReadResource={onReadResource}
isPollingTask={isPollingTask}
durationMs={lastCallDurationMs}
/>
</div>
) : (
Expand Down
82 changes: 81 additions & 1 deletion client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import ToolsTab, { ExtendedTool } from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import {
CompatibilityCallToolResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "../ui/tabs";
import { cacheToolOutputSchemas } from "../../utils/schemaUtils";
import { within } from "@testing-library/react";
Expand Down Expand Up @@ -1203,4 +1206,81 @@ describe("ToolsTab", () => {
expect(mockCallTool).toHaveBeenCalled();
});
});

describe("Tool call duration display", () => {
const successfulResult: CompatibilityCallToolResult = {
content: [{ type: "text", text: "ok" }],
};

it("should show the call duration next to the result after running a tool", async () => {
// Add a small but measurable delay so durationMs > 0 in jsdom.
const callToolMock = jest.fn(
() =>
new Promise<CompatibilityCallToolResult>((resolve) =>
setTimeout(() => resolve(successfulResult), 25),
),
);

renderToolsTab({
selectedTool: mockTools[0],
callTool: callToolMock,
toolResult: null,
});

const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});

// Re-render with the now-available result so the duration surfaces.
renderToolsTab({
selectedTool: mockTools[0],
callTool: callToolMock,
toolResult: successfulResult,
});

const durationEl = await screen.findByTestId("tool-call-duration");
expect(durationEl).toBeInTheDocument();
// The formatted duration is one of "<1 ms", "N ms", "N.NN s", or "Nm N.Ns".
expect(durationEl.textContent).toMatch(/ms|s|m\s/);
});
Comment on lines +1215 to +1246

it("should not show the call duration when none has been recorded", () => {
renderToolsTab({
selectedTool: mockTools[0],
toolResult: successfulResult,
});

expect(screen.queryByTestId("tool-call-duration")).not.toBeInTheDocument();
});

it("should clear the recorded duration when switching tools", async () => {
const callToolMock = jest.fn(async () => successfulResult);

const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
callTool: callToolMock,
toolResult: null,
});

const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});

// Switch to another tool - duration should be reset.
rerender(
<Tabs defaultValue="tools">
<ToolsTab
{...defaultProps}
selectedTool={mockTools[2]}
callTool={callToolMock}
toolResult={null}
/>
</Tabs>,
);

expect(screen.queryByTestId("tool-call-duration")).not.toBeInTheDocument();
});
});
});