Merge branch 'main' into claude/issue-1028-20260121-1235

This commit is contained in:
Ola Hungerford 2026-01-23 08:03:27 -07:00 • committed by GitHub
commit edbe2f84f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1479 additions and 108 deletions

View file

@ -16,6 +16,8 @@ import {
ServerNotification,
Tool,
LoggingLevel,
Task,
GetTaskResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
@ -55,6 +57,7 @@ import {
Hammer,
Hash,
Key,
ListTodo,
MessageSquare,
Settings,
} from "lucide-react";
@ -71,6 +74,7 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import TasksTab from "./components/TasksTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
@ -81,6 +85,7 @@ import {
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
getMCPTaskTtl,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
@ -124,12 +129,14 @@ const App = () => {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [toolResult, setToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const [errors, setErrors] = useState<Record<string, string | null>>({
resources: null,
prompts: null,
tools: null,
tasks: null,
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
@ -265,6 +272,8 @@ const App = () => {
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [isPollingTask, setIsPollingTask] = useState(false);
const [nextResourceCursor, setNextResourceCursor] = useState<
string | undefined
>();
@ -275,6 +284,7 @@ const App = () => {
string | undefined
>();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
@ -290,6 +300,32 @@ const App = () => {
currentTabRef.current = activeTab;
}, [activeTab]);
const navigateToOriginatingTab = (originatingTab?: string) => {
if (!originatingTab) return;
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
if (!validTabs.includes(originatingTab)) return;
setActiveTab(originatingTab);
window.location.hash = originatingTab;
setTimeout(() => {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
}, 100);
};
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
@ -297,6 +333,11 @@ const App = () => {
handleDragStart: handleSidebarDragStart,
} = useDraggableSidebar(320);
const selectedTaskRef = useRef<Task | null>(null);
useEffect(() => {
selectedTaskRef.current = selectedTask;
}, [selectedTask]);
const {
connectionStatus,
serverCapabilities,
@ -305,6 +346,8 @@ const App = () => {
requestHistory,
clearRequestHistory,
makeRequest,
cancelTask: cancelMcpTask,
listTasks: listMcpTasks,
sendNotification,
handleCompletion,
completionsSupported,
@ -324,12 +367,41 @@ const App = () => {
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
if (notification.method === "notifications/tasks/list_changed") {
void listTasks();
}
if (notification.method === "notifications/tasks/status") {
const task = notification.params as unknown as Task;
setTasks((prev) => {
const exists = prev.some((t) => t.taskId === task.taskId);
if (exists) {
return prev.map((t) => (t.taskId === task.taskId ? task : t));
} else {
return [task, ...prev];
}
});
if (selectedTaskRef.current?.taskId === task.taskId) {
setSelectedTask(task);
}
}
},
onPendingRequest: (request, resolve, reject) => {
const currentTab = lastToolCallOriginTabRef.current;
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
{
id: nextRequestId.current++,
request,
originatingTab: currentTab,
resolve,
reject,
},
]);
setActiveTab("sampling");
window.location.hash = "sampling";
},
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;
@ -367,6 +439,7 @@ const App = () => {
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
@ -383,7 +456,9 @@ const App = () => {
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
: serverCapabilities?.tasks
? "tasks"
: "ping";
setActiveTab(defaultTab);
window.location.hash = defaultTab;
@ -391,6 +466,13 @@ const App = () => {
}
}, [serverCapabilities]);
useEffect(() => {
if (mcpClient && activeTab === "tasks") {
void listTasks();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@ -610,7 +692,9 @@ const App = () => {
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
: serverCapabilities?.tasks
? "tasks"
: "ping";
window.location.hash = defaultTab;
} else if (!mcpClient && window.location.hash) {
// Clear hash when disconnected - completely remove the fragment
@ -638,6 +722,9 @@ const App = () => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
@ -646,6 +733,9 @@ const App = () => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
@ -666,6 +756,7 @@ const App = () => {
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
@ -841,6 +932,7 @@ const App = () => {
name: string,
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
@ -859,20 +951,164 @@ const App = () => {
...toolMetadata, // Tool-specific metadata
};
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: mergedMetadata,
},
const request: ClientRequest = {
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: mergedMetadata,
},
};
if (runAsTask) {
request.params = {
...request.params,
task: {
ttl: getMCPTaskTtl(config),
},
};
}
const response = await sendMCPRequest(
request,
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
// Check if this was a task-augmented request that returned a task reference
// The server returns { task: { taskId, status, ... } } when a task is created
const isTaskResult = (
res: unknown,
): res is {
task: { taskId: string; status: string; pollInterval: number };
} =>
!!res &&
typeof res === "object" &&
"task" in res &&
!!res.task &&
typeof res.task === "object" &&
"taskId" in res.task;
if (runAsTask && isTaskResult(response)) {
const taskId = response.task.taskId;
const pollInterval = response.task.pollInterval;
// Set polling state BEFORE setting tool result for proper UI update
setIsPollingTask(true);
// Safely extract any _meta from the original response (if present)
const initialResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task created: ${taskId}. Polling for status...`,
},
],
_meta: {
...(initialResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Polling loop
let taskCompleted = false;
while (!taskCompleted) {
try {
// Wait for 1 second before polling
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const taskStatus = await sendMCPRequest(
{
method: "tasks/get",
params: { taskId },
},
GetTaskResultSchema,
);
if (
taskStatus.status === "completed" ||
taskStatus.status === "failed" ||
taskStatus.status === "cancelled"
) {
taskCompleted = true;
console.log(
`Polling complete for task ${taskId}: ${taskStatus.status}`,
);
if (taskStatus.status === "completed") {
console.log(`Fetching result for task ${taskId}`);
const result = await sendMCPRequest(
{
method: "tasks/result",
params: { taskId },
},
CompatibilityCallToolResultSchema,
);
console.log(`Result received for task ${taskId}:`, result);
setToolResult(result as CompatibilityCallToolResult);
// Refresh tasks list to show completed state
void listTasks();
} else {
setToolResult({
content: [
{
type: "text",
text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`,
},
],
isError: true,
});
// Refresh tasks list to show failed/cancelled state
void listTasks();
}
} else {
// Update status message while polling
// Safely extract any _meta from the original response (if present)
const pollingResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ??
{})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`,
},
],
_meta: {
...(pollingResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Refresh tasks list to show progress
void listTasks();
}
} catch (pollingError) {
console.error("Error polling task status:", pollingError);
setToolResult({
content: [
{
type: "text",
text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`,
},
],
isError: true,
});
taskCompleted = true;
}
}
setIsPollingTask(false);
} else {
setToolResult(response as CompatibilityCallToolResult);
}
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
@ -891,6 +1127,37 @@ const App = () => {
}
};
const listTasks = useCallback(async () => {
try {
const response = await listMcpTasks(nextTaskCursor);
setTasks(response.tasks);
setNextTaskCursor(response.nextCursor);
// Inline error clear to avoid extra dependency on clearError
setErrors((prev) => ({ ...prev, tasks: null }));
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
}, [listMcpTasks, nextTaskCursor]);
const cancelTask = async (taskId: string) => {
try {
const response = await cancelMcpTask(taskId);
setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t)));
if (selectedTask?.taskId === taskId) {
setSelectedTask(response);
}
clearError("tasks");
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
};
const handleRootsChange = async () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
@ -1034,6 +1301,13 @@ const App = () => {
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
<TabsTrigger
value="tasks"
disabled={!serverCapabilities?.tasks}
>
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
@ -1182,10 +1456,11 @@ const App = () => {
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params, metadata);
await callTool(name, params, metadata, runAsTask);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1194,6 +1469,7 @@ const App = () => {
setToolResult(null);
}}
toolResult={toolResult}
isPollingTask={isPollingTask}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
@ -1202,6 +1478,25 @@ const App = () => {
readResource(uri);
}}
/>
<TasksTab
tasks={tasks}
listTasks={() => {
clearError("tasks");
listTasks();
}}
clearTasks={() => {
setTasks([]);
setNextTaskCursor(undefined);
}}
cancelTask={cancelTask}
selectedTask={selectedTask}
setSelectedTask={(task) => {
clearError("tasks");
setSelectedTask(task);
}}
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {

View file

@ -0,0 +1,239 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type {
CreateMessageRequest,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
type OnPendingRequestHandler = (
request: CreateMessageRequest,
resolve: (result: CreateMessageResult) => void,
reject: (error: Error) => void,
) => void;
type SamplingRequestMockProps = {
request: { id: number };
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
type UseConnectionReturn = ReturnType<typeof useConnection>;
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn(() => ({
token: "",
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => ({})),
saveInspectorConfig: jest.fn(),
}));
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
jest.mock("../lib/hooks/useToast", () => ({
useToast: () => ({ toast: jest.fn() }),
}));
// Keep the test focused on navigation; avoid DynamicJsonForm/schema complexity.
jest.mock("../components/SamplingRequest", () => ({
__esModule: true,
default: ({ request, onApprove, onReject }: SamplingRequestMockProps) => (
<div data-testid="sampling-request">
<div>sampling-request-{request.id}</div>
<button
type="button"
onClick={() =>
onApprove(request.id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: { type: "text", text: "" },
})
}
>
Approve
</button>
<button type="button" onClick={() => onReject(request.id)}>
Reject
</button>
</div>
),
}));
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: jest.fn(),
}));
describe("App - Sampling auto-navigation", () => {
const mockUseConnection = jest.mocked(useConnection);
const baseConnectionState = {
connectionStatus: "connected" as const,
serverCapabilities: { tools: { listChanged: true, subscribe: true } },
mcpClient: {
request: jest.fn(),
notification: jest.fn(),
close: jest.fn(),
} as unknown as Client,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
serverImplementation: null,
cancelTask: jest.fn(),
listTasks: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
window.location.hash = "#tools";
});
test("switches to #sampling when a sampling request arrives and switches back to #tools after approve", async () => {
let capturedOnPendingRequest: OnPendingRequestHandler | undefined;
mockUseConnection.mockImplementation((options) => {
capturedOnPendingRequest = (
options as { onPendingRequest?: OnPendingRequestHandler }
).onPendingRequest;
return baseConnectionState as unknown as UseConnectionReturn;
});
render(<App />);
// Ensure we start on tools.
await waitFor(() => {
expect(window.location.hash).toBe("#tools");
});
const resolve = jest.fn();
const reject = jest.fn();
act(() => {
if (!capturedOnPendingRequest) {
throw new Error("Expected onPendingRequest to be provided");
}
capturedOnPendingRequest(
{
method: "sampling/createMessage",
params: { messages: [], maxTokens: 1 },
},
resolve,
reject,
);
});
await waitFor(() => {
expect(window.location.hash).toBe("#sampling");
expect(screen.getByTestId("sampling-request")).toBeTruthy();
});
fireEvent.click(screen.getByText("Approve"));
await waitFor(() => {
expect(resolve).toHaveBeenCalled();
expect(window.location.hash).toBe("#tools");
});
});
test("switches back to #tools after reject", async () => {
let capturedOnPendingRequest: OnPendingRequestHandler | undefined;
mockUseConnection.mockImplementation((options) => {
capturedOnPendingRequest = (
options as { onPendingRequest?: OnPendingRequestHandler }
).onPendingRequest;
return baseConnectionState as unknown as UseConnectionReturn;
});
render(<App />);
await waitFor(() => {
expect(window.location.hash).toBe("#tools");
});
const resolve = jest.fn();
const reject = jest.fn();
act(() => {
if (!capturedOnPendingRequest) {
throw new Error("Expected onPendingRequest to be provided");
}
capturedOnPendingRequest(
{
method: "sampling/createMessage",
params: { messages: [], maxTokens: 1 },
},
resolve,
reject,
);
});
await waitFor(() => {
expect(window.location.hash).toBe("#sampling");
expect(screen.getByTestId("sampling-request")).toBeTruthy();
});
fireEvent.click(screen.getByText("Reject"));
await waitFor(() => {
expect(reject).toHaveBeenCalled();
expect(window.location.hash).toBe("#tools");
});
});
});

View file

@ -9,6 +9,7 @@ import SamplingRequest from "./SamplingRequest";
export type PendingRequest = {
id: number;
request: CreateMessageRequest;
originatingTab?: string;
};
export type Props = {

View file

@ -0,0 +1,229 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { Task } from "@modelcontextprotocol/sdk/types.js";
import {
AlertCircle,
RefreshCw,
XCircle,
Clock,
CheckCircle2,
AlertTriangle,
PlayCircle,
} from "lucide-react";
import ListPane from "./ListPane";
import { useState } from "react";
import JsonView from "./JsonView";
import { cn } from "@/lib/utils";
const TaskStatusIcon = ({ status }: { status: Task["status"] }) => {
switch (status) {
case "working":
return <Clock className="h-4 w-4 animate-pulse text-blue-500" />;
case "input_required":
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "cancelled":
return <XCircle className="h-4 w-4 text-gray-500" />;
default:
return <PlayCircle className="h-4 w-4" />;
}
};
const TasksTab = ({
tasks,
listTasks,
clearTasks,
cancelTask,
selectedTask,
setSelectedTask,
error,
nextCursor,
}: {
tasks: Task[];
listTasks: () => void;
clearTasks: () => void;
cancelTask: (taskId: string) => Promise<void>;
selectedTask: Task | null;
setSelectedTask: (task: Task | null) => void;
error: string | null;
nextCursor?: string;
}) => {
const [isCancelling, setIsCancelling] = useState<string | null>(null);
const displayedTask = selectedTask
? tasks.find((t) => t.taskId === selectedTask.taskId) || selectedTask
: null;
const handleCancel = async (taskId: string) => {
setIsCancelling(taskId);
try {
await cancelTask(taskId);
} finally {
setIsCancelling(null);
}
};
return (
<TabsContent value="tasks" className="flex-1 overflow-hidden p-0 m-0">
<div className="flex h-full overflow-hidden p-4 gap-4">
<div className="w-1/3">
<ListPane
title="Tasks"
items={tasks}
setSelectedItem={setSelectedTask}
listItems={listTasks}
clearItems={clearTasks}
buttonText={nextCursor ? "List More Tasks" : "List Tasks"}
isButtonDisabled={!nextCursor && tasks.length > 0}
renderItem={(task) => (
<div className="flex items-center gap-2 overflow-hidden w-full">
<TaskStatusIcon status={task.status} />
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium">{task.taskId}</span>
<span className="truncate text-xs text-muted-foreground">
{task.status} -{" "}
{new Date(task.lastUpdatedAt).toLocaleString()}
</span>
</div>
</div>
)}
/>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-background border border-border rounded-lg">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{displayedTask ? (
<div className="space-y-6">
<div className="flex items-center justify-between border-b pb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight">
Task Details
</h2>
<p className="text-muted-foreground">
ID: {displayedTask.taskId}
</p>
</div>
{(displayedTask.status === "working" ||
displayedTask.status === "input_required") && (
<Button
variant="destructive"
size="sm"
aria-label={`Cancel task ${displayedTask.taskId}`}
onClick={() => handleCancel(displayedTask.taskId)}
disabled={isCancelling === displayedTask.taskId}
>
{isCancelling === displayedTask.taskId ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<XCircle className="mr-2 h-4 w-4" />
)}
Cancel Task
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Status
</p>
<div className="mt-1 flex items-center gap-2">
<TaskStatusIcon status={displayedTask.status} />
<span
className={cn(
"font-semibold capitalize",
displayedTask.status === "working" && "text-blue-500",
displayedTask.status === "completed" &&
"text-green-500",
displayedTask.status === "failed" && "text-red-500",
displayedTask.status === "cancelled" && "text-gray-500",
displayedTask.status === "input_required" &&
"text-yellow-500",
)}
>
{displayedTask.status.replace("_", " ")}
</span>
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Last Updated
</p>
<p className="mt-1 font-medium">
{new Date(displayedTask.lastUpdatedAt).toLocaleString()}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Created At
</p>
<p className="mt-1 font-medium">
{new Date(displayedTask.createdAt).toLocaleString()}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
TTL
</p>
<p className="mt-1 font-medium">
{displayedTask.ttl === null
? "Infinite"
: `${displayedTask.ttl}ms`}
</p>
</div>
</div>
{displayedTask.statusMessage && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Status Message
</p>
<p className="mt-1 whitespace-pre-wrap">
{displayedTask.statusMessage}
</p>
</div>
)}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Full Task Object</h3>
<div className="rounded-md border">
<JsonView data={displayedTask} />
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-20" />
<h3 className="text-lg font-medium">No Task Selected</h3>
<p>Select a task from the list to view its details.</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={listTasks}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Tasks
</Button>
</div>
</div>
)}
</div>
</div>
</TabsContent>
);
};
export default TasksTab;

View file

@ -12,6 +12,7 @@ interface ToolResultsProps {
selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
isPollingTask?: boolean;
}
const checkContentCompatibility = (
@ -69,6 +70,7 @@ const ToolResults = ({
selectedTool,
resourceContent,
onReadResource,
isPollingTask,
}: ToolResultsProps) => {
if (!toolResult) return null;
@ -89,6 +91,19 @@ const ToolResults = ({
const structuredResult = parsedResult.data;
const isError = structuredResult.isError ?? false;
// Check if this is a running task
const relatedTask = structuredResult._meta?.[
"io.modelcontextprotocol/related-task"
] as { taskId: string } | undefined;
const isTaskRunning =
isPollingTask ||
(!!relatedTask &&
structuredResult.content.some(
(c) =>
c.type === "text" &&
(c.text?.includes("Polling") || c.text?.includes("Task status")),
));
let validationResult = null;
const toolHasOutputSchema =
selectedTool && hasOutputSchema(selectedTool.name);
@ -127,6 +142,8 @@ const ToolResults = ({
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : isTaskRunning ? (
<span className="text-yellow-600 font-semibold">Task Running</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}

View file

@ -64,6 +64,7 @@ const ToolsTab = ({
selectedTool,
setSelectedTool,
toolResult,
isPollingTask,
nextCursor,
error,
resourceContent,
@ -76,16 +77,19 @@ const ToolsTab = ({
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<void>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
isPollingTask?: boolean;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [runAsTask, setRunAsTask] = useState(false);
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
@ -125,6 +129,7 @@ const ToolsTab = ({
];
});
setParams(Object.fromEntries(params));
setRunAsTask(false);
// Reset validation errors when switching tools
setHasValidationErrors(false);
@ -157,6 +162,7 @@ const ToolsTab = ({
clearItems={() => {
clearTools();
setSelectedTool(null);
setRunAsTask(false);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
@ -651,6 +657,21 @@ const ToolsTab = ({
</div>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="run-as-task"
checked={runAsTask}
onCheckedChange={(checked: boolean) =>
setRunAsTask(checked)
}
/>
<Label
htmlFor="run-as-task"
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
Run as task
</Label>
</div>
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
@ -676,6 +697,7 @@ const ToolsTab = ({
selectedTool.name,
params,
Object.keys(metadata).length ? metadata : undefined,
runAsTask,
);
} finally {
setIsToolRunning(false);
@ -683,16 +705,17 @@ const ToolsTab = ({
}}
disabled={
isToolRunning ||
isPollingTask ||
hasValidationErrors ||
hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry
}
>
{isToolRunning ? (
{isToolRunning || isPollingTask ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
{isPollingTask ? "Polling Task..." : "Running..."}
</>
) : (
<>
@ -731,6 +754,7 @@ const ToolsTab = ({
selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
isPollingTask={isPollingTask}
/>
</div>
) : (

View file

@ -3,14 +3,14 @@ import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
import { Tabs } from "../ui/tabs";
import { cacheToolOutputSchemas } from "../../utils/schemaUtils";
import { within } from "@testing-library/react";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
} from "../../utils/metaUtils";
describe("ToolsTab", () => {
beforeEach(() => {
@ -130,6 +130,7 @@ describe("ToolsTab", () => {
count: 42,
},
undefined,
false,
);
});
@ -155,6 +156,7 @@ describe("ToolsTab", () => {
num: -42,
},
undefined,
false,
);
});
@ -191,6 +193,7 @@ describe("ToolsTab", () => {
num: null,
},
undefined,
false,
);
});
@ -234,6 +237,7 @@ describe("ToolsTab", () => {
optionalBoolean: null,
},
undefined,
false,
);
// State 2: Uncheck null checkbox -> should set value to false and enable input
@ -255,6 +259,7 @@ describe("ToolsTab", () => {
optionalBoolean: false,
},
undefined,
false,
);
// State 3: Check boolean checkbox -> should set value to true
@ -277,6 +282,7 @@ describe("ToolsTab", () => {
optionalBoolean: true,
},
undefined,
false,
);
// State 4: Check null checkbox again -> should set value back to null and disable input
@ -295,6 +301,7 @@ describe("ToolsTab", () => {
optionalBoolean: null,
},
undefined,
false,
);
});
@ -816,6 +823,7 @@ describe("ToolsTab", () => {
mockTools[0].name,
expect.any(Object),
{ requestId: "abc123" },
false,
);
});
});
@ -1080,6 +1088,7 @@ describe("ToolsTab", () => {
count: 5,
},
undefined,
false,
);
});

View file

@ -40,4 +40,9 @@ export type InspectorConfig = {
* Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup.
*/
MCP_PROXY_AUTH_TOKEN: ConfigItem;
/**
* Default Time-to-Live (TTL) in milliseconds for newly created tasks.
*/
MCP_TASK_TTL: ConfigItem;
};

View file

@ -76,4 +76,11 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
value: "",
is_session_item: true,
},
MCP_TASK_TTL: {
label: "Task TTL",
description:
"Default Time-to-Live (TTL) in milliseconds for newly created tasks",
value: 60000,
is_session_item: false,
},
} as const;

View file

@ -1,10 +1,15 @@
import { renderHook, act } from "@testing-library/react";
import { useConnection } from "../useConnection";
import { z } from "zod";
import { z } from "zod/v3";
import {
ClientRequest,
CreateTaskResultSchema,
JSONRPCMessage,
} from "@modelcontextprotocol/sdk/types.js";
import type {
AnySchema,
SchemaOutput,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants";
import {
SSEClientTransportOptions,
@ -128,7 +133,7 @@ const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
>;
describe("useConnection", () => {
const defaultProps = {
const defaultProps: Parameters<typeof useConnection>[0] = {
transportType: "sse" as const,
command: "",
args: "",
@ -164,8 +169,10 @@ describe("useConnection", () => {
test: z.string(),
});
const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema;
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema);
await result.current.makeRequest(mockRequest, mockSchemaAny);
});
expect(mockClient.request).toHaveBeenCalledWith(
@ -204,8 +211,10 @@ describe("useConnection", () => {
test: z.string(),
});
const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema;
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema, {
await result.current.makeRequest(mockRequest, mockSchemaAny, {
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
@ -224,8 +233,251 @@ describe("useConnection", () => {
});
});
describe("Receiver-side Tasks (task-augmented incoming requests)", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("declares tasks.requests.sampling.createMessage when onPendingRequest is provided", async () => {
const Client = jest.requireMock(
"@modelcontextprotocol/sdk/client/index.js",
).Client;
const propsWithPending = {
...defaultProps,
onPendingRequest: jest.fn(),
};
const { result } = renderHook(() => useConnection(propsWithPending));
await act(async () => {
await result.current.connect();
});
expect(Client).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
capabilities: expect.objectContaining({
tasks: expect.objectContaining({
requests: expect.objectContaining({
sampling: expect.objectContaining({
createMessage: {},
}),
}),
}),
}),
}),
);
});
test("task-augmented sampling/createMessage returns { task } and tasks/result blocks until resolved", async () => {
let pendingResolve: ((value: unknown) => void) | undefined;
let pendingReject: ((reason?: unknown) => void) | undefined;
const mockOnPendingRequest = jest.fn((_request, resolve, reject) => {
pendingResolve = resolve;
pendingReject = reject;
});
const propsWithPending = {
...defaultProps,
onPendingRequest: mockOnPendingRequest,
};
const { result } = renderHook(() => useConnection(propsWithPending));
await act(async () => {
await result.current.connect();
});
const samplingRequest = {
method: "sampling/createMessage",
params: {
task: { ttl: 0 },
messages: [
{
role: "user",
content: { type: "text", text: "hello" },
},
],
maxTokens: 1,
},
};
// Locate the sampling/createMessage handler
const samplingHandlerCall = mockClient.setRequestHandler.mock.calls.find(
(call) => {
try {
const schema = call[0];
const parseResult =
schema.safeParse && schema.safeParse(samplingRequest);
return parseResult?.success;
} catch {
return false;
}
},
);
expect(samplingHandlerCall).toBeDefined();
const [, samplingHandler] = samplingHandlerCall;
// Invoke handler; should return a CreateTaskResult immediately
let createTaskResult: SchemaOutput<typeof CreateTaskResultSchema>;
await act(async () => {
createTaskResult = await samplingHandler(samplingRequest);
});
expect(createTaskResult).toHaveProperty("task");
expect(createTaskResult.task).toEqual(
expect.objectContaining({
taskId: expect.any(String),
status: "input_required",
ttl: 0,
createdAt: expect.any(String),
lastUpdatedAt: expect.any(String),
}),
);
expect(mockOnPendingRequest).toHaveBeenCalledTimes(1);
expect(pendingResolve).toBeDefined();
expect(pendingReject).toBeDefined();
const taskId = createTaskResult.task.taskId as string;
// Locate tasks/get and tasks/result handlers
const taskGetRequest = { method: "tasks/get", params: { taskId } };
const taskResultRequest = { method: "tasks/result", params: { taskId } };
const taskGetHandlerCall = mockClient.setRequestHandler.mock.calls.find(
(call) => {
try {
const schema = call[0];
const parseResult =
schema.safeParse && schema.safeParse(taskGetRequest);
return parseResult?.success;
} catch {
return false;
}
},
);
const taskResultHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const parseResult =
schema.safeParse && schema.safeParse(taskResultRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(taskGetHandlerCall).toBeDefined();
expect(taskResultHandlerCall).toBeDefined();
const [, taskGetHandler] = taskGetHandlerCall;
const [, taskResultHandler] = taskResultHandlerCall;
// Verify tasks/get sees the in-progress task
const getBefore = await taskGetHandler(taskGetRequest);
expect(getBefore.status).toBe("input_required");
// tasks/result should block until user flow resolves
const payloadPromise = taskResultHandler(taskResultRequest);
const race = await Promise.race([
payloadPromise.then(() => "resolved"),
new Promise((r) => setTimeout(() => r("timeout"), 10)),
]);
expect(race).toBe("timeout");
const mockPayload = {
model: "test-model",
role: "assistant",
content: { type: "text", text: "ok" },
};
await act(async () => {
pendingResolve!(mockPayload);
// Let the background updater run
await new Promise((r) => setTimeout(r, 0));
});
await expect(payloadPromise).resolves.toEqual(mockPayload);
const getAfter = await taskGetHandler(taskGetRequest);
expect(getAfter.status).toBe("completed");
});
test("task-augmented elicitation/create returns { task } immediately", async () => {
const mockOnElicitationRequest = jest.fn();
const propsWithElicitation = {
...defaultProps,
onElicitationRequest: mockOnElicitationRequest,
};
const { result } = renderHook(() => useConnection(propsWithElicitation));
await act(async () => {
await result.current.connect();
});
const elicitationRequest = {
method: "elicitation/create",
params: {
task: { ttl: 0 },
message: "Please provide your name",
requestedSchema: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
},
},
};
const elicitRequestHandlerCall =
mockClient.setRequestHandler.mock.calls.find((call) => {
try {
const schema = call[0];
const parseResult =
schema.safeParse && schema.safeParse(elicitationRequest);
return parseResult?.success;
} catch {
return false;
}
});
expect(elicitRequestHandlerCall).toBeDefined();
const [, handler] = elicitRequestHandlerCall;
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
resolve({ action: "accept", content: { name: "test" } });
});
const resultValue = await handler(elicitationRequest);
expect(resultValue).toHaveProperty("task");
expect(resultValue.task).toEqual(
expect.objectContaining({
taskId: expect.any(String),
status: "input_required",
ttl: 0,
}),
);
});
});
test("throws error when mcpClient is not connected", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
const { result } = renderHook(() => {
const { makeRequest } = useConnection(defaultProps) as unknown as {
makeRequest: (
request: ClientRequest,
schema: AnySchema,
) => Promise<unknown>;
};
return { makeRequest };
});
const mockRequest: ClientRequest = {
method: "ping",
@ -236,8 +488,10 @@ describe("useConnection", () => {
test: z.string(),
});
const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema;
await expect(
result.current.makeRequest(mockRequest, mockSchema),
result.current.makeRequest(mockRequest, mockSchemaAny),
).rejects.toThrow("MCP client not connected");
});

View file

@ -12,6 +12,7 @@ import {
import {
ClientNotification,
ClientRequest,
ClientResult,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ResourceUpdatedNotificationSchema,
@ -32,13 +33,22 @@ import {
LoggingLevel,
ElicitRequestSchema,
Implementation,
Task,
CreateTaskResultSchema,
GetTaskRequestSchema,
GetTaskPayloadRequestSchema,
ListTasksRequestSchema,
CancelTaskRequestSchema,
ListTasksResultSchema,
CancelTaskResultSchema,
TaskStatusNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type {
AnySchema,
SchemaOutput,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { ConnectionStatus, CLIENT_IDENTITY } from "../constants";
import { Notification } from "../notificationTypes";
@ -56,6 +66,7 @@ import {
} from "../auth";
import {
getMCPProxyAddress,
getMCPTaskTtl,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
getMCPProxyAuthToken,
@ -131,6 +142,18 @@ export function useConnection({
const [serverImplementation, setServerImplementation] =
useState<Implementation | null>(null);
type ReceiverTaskRecord = {
task: Task;
payloadPromise: Promise<ClientResult>;
resolvePayload: (payload: ClientResult) => void;
rejectPayload: (reason?: unknown) => void;
cleanupTimeoutId?: ReturnType<typeof setTimeout>;
};
// Tasks created locally in response to *incoming* task-augmented requests
// (e.g. `sampling/createMessage` and `elicitation/create` with `params.task`).
const receiverTasksRef = useRef<Map<string, ReceiverTaskRecord>>(new Map());
useEffect(() => {
if (!oauthClientId) {
clearClientInformationFromSessionStorage({
@ -433,6 +456,22 @@ export function useConnection({
roots: {
listChanged: true,
},
tasks: {
list: {},
cancel: {},
...(onPendingRequest || onElicitationRequest
? {
requests: {
...(onPendingRequest
? { sampling: { createMessage: {} } }
: undefined),
...(onElicitationRequest
? { elicitation: { create: {} } }
: undefined),
},
}
: undefined),
},
},
};
@ -714,6 +753,7 @@ export function useConnection({
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
TaskStatusNotificationSchema,
].forEach((notificationSchema) => {
client.setNotificationHandler(notificationSchema, onNotification);
});
@ -793,11 +833,218 @@ export function useConnection({
setServerCapabilities(capabilities ?? null);
setCompletionsSupported(capabilities?.completions !== undefined);
const nowIso = () => new Date().toISOString();
const makeTaskId = () => {
// Prefer UUID when available; otherwise fall back to a reasonably unique id.
const cryptoAny = globalThis.crypto as unknown as
| { randomUUID?: () => string }
| undefined;
return (
cryptoAny?.randomUUID?.() ??
`task_${Date.now()}_${Math.random().toString(16).slice(2)}`
);
};
const emitTaskStatus = async (task: Task) => {
// Best-effort; task status notifications are optional.
try {
const notification: ClientNotification = {
method: "notifications/tasks/status",
params: task,
} as unknown as ClientNotification;
await client.notification(notification);
pushHistory(notification);
} catch (e) {
console.warn("Failed to send notifications/tasks/status", e);
}
};
const upsertReceiverTask = async (task: Task) => {
// Update task record and emit status notification.
const record = receiverTasksRef.current.get(task.taskId);
if (record) {
receiverTasksRef.current.set(task.taskId, { ...record, task });
}
await emitTaskStatus(task);
};
const createReceiverTask = (opts: {
ttl?: number;
initialStatus: Task["status"];
statusMessage?: string;
pollInterval?: number;
}): ReceiverTaskRecord => {
const taskId = makeTaskId();
const createdAt = nowIso();
const ttl = opts.ttl ?? getMCPTaskTtl(config);
let resolvePayload: (payload: ClientResult) => void = () => undefined;
let rejectPayload: (reason?: unknown) => void = () => undefined;
const payloadPromise = new Promise<ClientResult>((resolve, reject) => {
resolvePayload = resolve;
rejectPayload = reject;
});
const task: Task = {
taskId,
status: opts.initialStatus,
ttl,
createdAt,
lastUpdatedAt: createdAt,
...(opts.pollInterval !== undefined
? { pollInterval: opts.pollInterval }
: undefined),
...(opts.statusMessage ? { statusMessage: opts.statusMessage } : {}),
};
const record: ReceiverTaskRecord = {
task,
payloadPromise,
resolvePayload,
rejectPayload,
};
// Cleanup after TTL (best-effort).
if (ttl !== null && ttl > 0) {
record.cleanupTimeoutId = setTimeout(() => {
receiverTasksRef.current.delete(taskId);
}, ttl);
}
receiverTasksRef.current.set(taskId, record);
void emitTaskStatus(task);
return record;
};
// Server -> client Tasks handlers (receiver side)
client.setRequestHandler(ListTasksRequestSchema, async () => {
return {
tasks: Array.from(receiverTasksRef.current.values()).map(
(r) => r.task,
),
};
});
client.setRequestHandler(GetTaskRequestSchema, async (request) => {
const record = receiverTasksRef.current.get(request.params.taskId);
if (!record) {
throw new McpError(
ErrorCode.InvalidParams,
`Unknown taskId: ${request.params.taskId}`,
);
}
return record.task;
});
client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {
const record = receiverTasksRef.current.get(request.params.taskId);
if (!record) {
throw new McpError(
ErrorCode.InvalidParams,
`Unknown taskId: ${request.params.taskId}`,
);
}
// Block until the task payload is ready.
return await record.payloadPromise;
});
client.setRequestHandler(CancelTaskRequestSchema, async (request) => {
const record = receiverTasksRef.current.get(request.params.taskId);
if (!record) {
throw new McpError(
ErrorCode.InvalidParams,
`Unknown taskId: ${request.params.taskId}`,
);
}
const terminalStatuses: Task["status"][] = [
"completed",
"failed",
"cancelled",
];
if (!terminalStatuses.includes(record.task.status)) {
const updated: Task = {
...record.task,
status: "cancelled",
lastUpdatedAt: nowIso(),
statusMessage: "Cancelled",
};
receiverTasksRef.current.set(request.params.taskId, {
...record,
task: updated,
});
// Unblock any pending `tasks/result`.
record.rejectPayload(
new McpError(ErrorCode.InternalError, "Task was cancelled"),
);
await emitTaskStatus(updated);
}
return receiverTasksRef.current.get(request.params.taskId)!.task;
});
if (onPendingRequest) {
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
const taskSpec = (request as { params?: { task?: { ttl?: number } } })
.params?.task;
if (!taskSpec) {
return new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
}
// Task-augmented sampling request: return a task immediately and
// allow the server to poll via `tasks/get` and `tasks/result`.
const record = createReceiverTask({
ttl: taskSpec.ttl,
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
// Background runner to complete and resolve this specific task record.
void (async () => {
try {
const payload = await new Promise((resolve, reject) => {
onPendingRequest(request, resolve, reject);
});
record.resolvePayload(payload as ClientResult);
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: nowIso(),
};
receiverTasksRef.current.set(record.task.taskId, {
...record,
task: updated,
});
await upsertReceiverTask(updated);
} catch (e) {
record.rejectPayload(e);
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: nowIso(),
statusMessage: e instanceof Error ? e.message : "Task failed",
};
receiverTasksRef.current.set(record.task.taskId, {
...record,
task: updated,
});
await upsertReceiverTask(updated);
}
})();
const createTaskResult: SchemaOutput<typeof CreateTaskResultSchema> =
{
task: record.task,
};
return createTaskResult;
});
}
@ -807,6 +1054,64 @@ export function useConnection({
});
}
if (onElicitationRequest) {
client.setRequestHandler(ElicitRequestSchema, (request) => {
const taskSpec = (request as { params?: { task?: { ttl?: number } } })
.params?.task;
if (!taskSpec) {
return new Promise((resolve) => {
onElicitationRequest(request, resolve);
});
}
const record = createReceiverTask({
ttl: taskSpec.ttl,
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
// Run elicitation flow and resolve the task payload.
void (async () => {
try {
const payload = await new Promise((resolve) => {
onElicitationRequest(request, resolve);
});
record.resolvePayload(payload as ClientResult);
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: nowIso(),
};
receiverTasksRef.current.set(record.task.taskId, {
...record,
task: updated,
});
await upsertReceiverTask(updated);
} catch (e) {
record.rejectPayload(e);
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: nowIso(),
statusMessage: e instanceof Error ? e.message : "Task failed",
};
receiverTasksRef.current.set(record.task.taskId, {
...record,
task: updated,
});
await upsertReceiverTask(updated);
}
})();
const createTaskResult: SchemaOutput<typeof CreateTaskResultSchema> =
{
task: record.task,
};
return createTaskResult;
});
}
if (capabilities?.logging && defaultLoggingLevel) {
lastRequest = "logging/setLevel";
await client.setLoggingLevel(defaultLoggingLevel);
@ -822,14 +1127,6 @@ export function useConnection({
lastRequest = "";
}
if (onElicitationRequest) {
client.setRequestHandler(ElicitRequestSchema, async (request) => {
return new Promise((resolve) => {
onElicitationRequest(request, resolve);
});
});
}
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
@ -855,7 +1152,35 @@ export function useConnection({
}
};
const cancelTask = async (taskId: string) => {
return makeRequest(
{
method: "tasks/cancel",
params: { taskId },
},
CancelTaskResultSchema,
);
};
const listTasks = async (cursor?: string) => {
return makeRequest(
{
method: "tasks/list",
params: { cursor },
},
ListTasksResultSchema,
);
};
const disconnect = async () => {
// Clear any receiver-side tasks + cleanup timers
receiverTasksRef.current.forEach((record) => {
if (record.cleanupTimeoutId) {
clearTimeout(record.cleanupTimeoutId);
}
});
receiverTasksRef.current.clear();
if (transportType === "streamable-http")
await (
clientTransport as StreamableHTTPClientTransport
@ -885,6 +1210,8 @@ export function useConnection({
requestHistory,
clearRequestHistory,
makeRequest,
cancelTask,
listTasks,
sendNotification,
handleCompletion,
completionsSupported,

View file

@ -54,6 +54,9 @@ export const getMCPProxyAuthToken = (
header: "X-MCP-Proxy-Auth",
};
};
export const getMCPTaskTtl = (config: InspectorConfig): number => {
return config.MCP_TASK_TTL.value as number;
};
export const getInitialTransportType = ():
| "stdio"