Skip to content

Commit e353435

Browse files
Add proper support for progress flow during tool calling
1 parent 06f237b commit e353435

File tree

11 files changed

+204
-61
lines changed

11 files changed

+204
-61
lines changed

client/src/App.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -411,21 +411,34 @@ const App = () => {
411411
};
412412

413413
const callTool = async (name: string, params: Record<string, unknown>) => {
414-
const response = await makeConnectionRequest(
415-
{
416-
method: "tools/call" as const,
417-
params: {
418-
name,
419-
arguments: params,
420-
_meta: {
421-
progressToken: progressTokenRef.current++,
414+
try {
415+
const response = await makeConnectionRequest(
416+
{
417+
method: "tools/call" as const,
418+
params: {
419+
name,
420+
arguments: params,
421+
_meta: {
422+
progressToken: progressTokenRef.current++,
423+
},
422424
},
423425
},
424-
},
425-
CompatibilityCallToolResultSchema,
426-
"tools",
427-
);
428-
setToolResult(response);
426+
CompatibilityCallToolResultSchema,
427+
"tools",
428+
);
429+
setToolResult(response);
430+
} catch (e) {
431+
const toolResult: CompatibilityCallToolResult = {
432+
content: [
433+
{
434+
type: "text",
435+
text: (e as Error).message ?? String(e),
436+
},
437+
],
438+
isError: true,
439+
};
440+
setToolResult(toolResult);
441+
}
429442
};
430443

431444
const handleRootsChange = async () => {
@@ -633,9 +646,10 @@ const App = () => {
633646
setTools([]);
634647
setNextToolCursor(undefined);
635648
}}
636-
callTool={(name, params) => {
649+
callTool={async (name, params) => {
637650
clearError("tools");
638-
callTool(name, params);
651+
setToolResult(null);
652+
await callTool(name, params);
639653
}}
640654
selectedTool={selectedTool}
641655
setSelectedTool={(tool) => {

client/src/components/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ const Sidebar = ({
325325
return (
326326
<div key={key} className="space-y-2">
327327
<div className="flex items-center gap-1">
328-
<label className="text-sm font-medium text-green-600">
328+
<label className="text-sm font-medium text-green-600 break-all">
329329
{configKey}
330330
</label>
331331
<Tooltip>

client/src/components/ToolsTab.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ListToolsResult,
1414
Tool,
1515
} from "@modelcontextprotocol/sdk/types.js";
16-
import { Send } from "lucide-react";
16+
import { Loader2, Send } from "lucide-react";
1717
import { useEffect, useState } from "react";
1818
import ListPane from "./ListPane";
1919
import JsonView from "./JsonView";
@@ -31,14 +31,16 @@ const ToolsTab = ({
3131
tools: Tool[];
3232
listTools: () => void;
3333
clearTools: () => void;
34-
callTool: (name: string, params: Record<string, unknown>) => void;
34+
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
3535
selectedTool: Tool | null;
3636
setSelectedTool: (tool: Tool | null) => void;
3737
toolResult: CompatibilityCallToolResult | null;
3838
nextCursor: ListToolsResult["nextCursor"];
3939
error: string | null;
4040
}) => {
4141
const [params, setParams] = useState<Record<string, unknown>>({});
42+
const [isToolRunning, setIsToolRunning] = useState(false);
43+
4244
useEffect(() => {
4345
setParams({});
4446
}, [selectedTool]);
@@ -234,9 +236,28 @@ const ToolsTab = ({
234236
);
235237
},
236238
)}
237-
<Button onClick={() => callTool(selectedTool.name, params)}>
238-
<Send className="w-4 h-4 mr-2" />
239-
Run Tool
239+
<Button
240+
onClick={async () => {
241+
try {
242+
setIsToolRunning(true);
243+
await callTool(selectedTool.name, params);
244+
} finally {
245+
setIsToolRunning(false);
246+
}
247+
}}
248+
disabled={isToolRunning}
249+
>
250+
{isToolRunning ? (
251+
<>
252+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
253+
Running...
254+
</>
255+
) : (
256+
<>
257+
<Send className="w-4 h-4 mr-2" />
258+
Run Tool
259+
</>
260+
)}
240261
</Button>
241262
{toolResult && renderToolResult()}
242263
</div>

client/src/components/__tests__/Sidebar.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,54 @@ describe("Sidebar Environment Variables", () => {
350350
);
351351
});
352352

353+
it("should update MCP server proxy address", () => {
354+
const setConfig = jest.fn();
355+
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
356+
357+
openConfigSection();
358+
359+
const proxyAddressInput = screen.getByTestId(
360+
"MCP_PROXY_FULL_ADDRESS-input",
361+
);
362+
fireEvent.change(proxyAddressInput, {
363+
target: { value: "http://localhost:8080" },
364+
});
365+
366+
expect(setConfig).toHaveBeenCalledWith(
367+
expect.objectContaining({
368+
MCP_PROXY_FULL_ADDRESS: {
369+
description:
370+
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
371+
value: "http://localhost:8080",
372+
},
373+
}),
374+
);
375+
});
376+
377+
it("should update max total timeout", () => {
378+
const setConfig = jest.fn();
379+
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
380+
381+
openConfigSection();
382+
383+
const maxTotalTimeoutInput = screen.getByTestId(
384+
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
385+
);
386+
fireEvent.change(maxTotalTimeoutInput, {
387+
target: { value: "10000" },
388+
});
389+
390+
expect(setConfig).toHaveBeenCalledWith(
391+
expect.objectContaining({
392+
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
393+
description:
394+
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
395+
value: 10000,
396+
},
397+
}),
398+
);
399+
});
400+
353401
it("should handle invalid timeout values entered by user", () => {
354402
const setConfig = jest.fn();
355403
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });

client/src/components/__tests__/ToolsTab.test.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent } from "@testing-library/react";
1+
import { render, screen, fireEvent, act } from "@testing-library/react";
22
import { describe, it, expect, jest } from "@jest/globals";
33
import "@testing-library/jest-dom";
44
import ToolsTab from "../ToolsTab";
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
4343
tools: mockTools,
4444
listTools: jest.fn(),
4545
clearTools: jest.fn(),
46-
callTool: jest.fn(),
46+
callTool: jest.fn(async () => {}),
4747
selectedTool: null,
4848
setSelectedTool: jest.fn(),
4949
toolResult: null,
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
5959
);
6060
};
6161

62-
it("should reset input values when switching tools", () => {
62+
it("should reset input values when switching tools", async () => {
6363
const { rerender } = renderToolsTab({
6464
selectedTool: mockTools[0],
6565
});
6666

6767
// Enter a value in the first tool's input
6868
const input = screen.getByRole("spinbutton") as HTMLInputElement;
69-
fireEvent.change(input, { target: { value: "42" } });
69+
await act(async () => {
70+
fireEvent.change(input, { target: { value: "42" } });
71+
});
7072
expect(input.value).toBe("42");
7173

7274
// Switch to second tool
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
8082
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
8183
expect(newInput.value).toBe("");
8284
});
83-
it("should handle integer type inputs", () => {
85+
86+
it("should handle integer type inputs", async () => {
8487
renderToolsTab({
8588
selectedTool: mockTools[1], // Use the tool with integer type
8689
});
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
9396
expect(input.value).toBe("42");
9497

9598
const submitButton = screen.getByRole("button", { name: /run tool/i });
96-
fireEvent.click(submitButton);
99+
await act(async () => {
100+
fireEvent.click(submitButton);
101+
});
97102

98103
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
99104
count: 42,
100105
});
101106
});
107+
108+
it("should disable button and change text while tool is running", async () => {
109+
// Create a promise that we can resolve later
110+
let resolvePromise: ((value: unknown) => void) | undefined;
111+
const mockPromise = new Promise((resolve) => {
112+
resolvePromise = resolve;
113+
});
114+
115+
// Mock callTool to return our promise
116+
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
117+
118+
renderToolsTab({
119+
selectedTool: mockTools[0],
120+
callTool: mockCallTool,
121+
});
122+
123+
const submitButton = screen.getByRole("button", { name: /run tool/i });
124+
expect(submitButton.getAttribute("disabled")).toBeNull();
125+
126+
// Click the button and verify immediate state changes
127+
await act(async () => {
128+
fireEvent.click(submitButton);
129+
});
130+
131+
// Verify button is disabled and text changed
132+
expect(submitButton.getAttribute("disabled")).not.toBeNull();
133+
expect(submitButton.textContent).toBe("Running...");
134+
135+
// Resolve the promise to simulate tool completion
136+
await act(async () => {
137+
if (resolvePromise) {
138+
await resolvePromise({});
139+
}
140+
});
141+
142+
expect(submitButton.getAttribute("disabled")).toBeNull();
143+
});
102144
});

client/src/lib/configurationTypes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ export type InspectorConfig = {
2020
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
2121
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
2222
*/
23-
MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
23+
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
2424

2525
/**
2626
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
2727
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
2828
*/
29-
MCP_SERVER_REQUEST_TIMEOUT_MAX_TOTAL_TIMEOUT: ConfigItem;
29+
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
3030

3131
/**
3232
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577

client/src/lib/constants.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
2525
description: "Timeout for requests to the MCP server (ms)",
2626
value: 10000,
2727
},
28-
MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
28+
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
2929
description: "Reset timeout on progress notifications",
3030
value: true,
3131
},
32-
MCP_SERVER_REQUEST_TIMEOUT_MAX_TOTAL_TIMEOUT: {
33-
description: "Maximum total timeout for requests sent to the MCP server (ms)",
32+
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
33+
description:
34+
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
3435
value: 60000,
3536
},
3637
MCP_PROXY_FULL_ADDRESS: {

client/src/lib/hooks/__tests__/useConnection.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,10 @@ describe("useConnection", () => {
9595
expect.objectContaining({
9696
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
9797
maxTotalTimeout:
98-
DEFAULT_INSPECTOR_CONFIG
99-
.MCP_SERVER_REQUEST_TIMEOUT_MAX_TOTAL_TIMEOUT.value,
98+
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
10099
resetTimeoutOnProgress:
101-
DEFAULT_INSPECTOR_CONFIG
102-
.MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value,
100+
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
101+
.value,
103102
}),
104103
);
105104
});

0 commit comments

Comments
 (0)