diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..51b1d6c22 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); + const [tasks, setTasks] = useState([]); const [toolResult, setToolResult] = useState(null); const [errors, setErrors] = useState>({ resources: null, prompts: null, tools: null, + tasks: null, }); const [command, setCommand] = useState(getInitialCommand); const [args, setArgs] = useState(getInitialArgs); @@ -265,6 +272,8 @@ const App = () => { const [selectedPrompt, setSelectedPrompt] = useState(null); const [selectedTool, setSelectedTool] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [isPollingTask, setIsPollingTask] = useState(false); const [nextResourceCursor, setNextResourceCursor] = useState< string | undefined >(); @@ -275,6 +284,7 @@ const App = () => { string | undefined >(); const [nextToolCursor, setNextToolCursor] = useState(); + const [nextTaskCursor, setNextTaskCursor] = useState(); const progressTokenRef = useRef(0); const [activeTab, setActiveTab] = useState(() => { @@ -297,6 +307,11 @@ const App = () => { handleDragStart: handleSidebarDragStart, } = useDraggableSidebar(320); + const selectedTaskRef = useRef(null); + useEffect(() => { + selectedTaskRef.current = selectedTask; + }, [selectedTask]); + const { connectionStatus, serverCapabilities, @@ -305,6 +320,8 @@ const App = () => { requestHistory, clearRequestHistory, makeRequest, + cancelTask: cancelMcpTask, + listTasks: listMcpTasks, sendNotification, handleCompletion, completionsSupported, @@ -324,6 +341,25 @@ 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) => { setPendingSampleRequests((prev) => [ @@ -367,6 +403,7 @@ const App = () => { ...(serverCapabilities?.resources ? ["resources"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tasks ? ["tasks"] : []), "ping", "sampling", "elicitations", @@ -383,7 +420,9 @@ const App = () => { ? "prompts" : serverCapabilities?.tools ? "tools" - : "ping"; + : serverCapabilities?.tasks + ? "tasks" + : "ping"; setActiveTab(defaultTab); window.location.hash = defaultTab; @@ -391,6 +430,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 +656,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 @@ -666,6 +714,7 @@ const App = () => { ...(serverCapabilities?.resources ? ["resources"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tasks ? ["tasks"] : []), "ping", "sampling", "elicitations", @@ -841,6 +890,7 @@ const App = () => { name: string, params: Record, toolMetadata?: Record, + runAsTask?: boolean, ) => { lastToolCallOriginTabRef.current = currentTabRef.current; @@ -859,20 +909,161 @@ 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 } } => + !!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; + // 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) + ? ((response as { _meta?: Record })._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, 1000)); + + 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 }, + }, + z.any(), + ); + 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) + ? ((response as { _meta?: Record })._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 +1082,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 +1256,13 @@ const App = () => { Tools + + + Tasks + Ping @@ -1182,10 +1411,11 @@ const App = () => { name: string, params: Record, metadata?: Record, + runAsTask?: boolean, ) => { clearError("tools"); setToolResult(null); - await callTool(name, params, metadata); + await callTool(name, params, metadata, runAsTask); }} selectedTool={selectedTool} setSelectedTool={(tool) => { @@ -1194,6 +1424,7 @@ const App = () => { setToolResult(null); }} toolResult={toolResult} + isPollingTask={isPollingTask} nextCursor={nextToolCursor} error={errors.tools} resourceContent={resourceContentMap} @@ -1202,6 +1433,25 @@ const App = () => { readResource(uri); }} /> + { + clearError("tasks"); + listTasks(); + }} + clearTasks={() => { + setTasks([]); + setNextTaskCursor(undefined); + }} + cancelTask={cancelTask} + selectedTask={selectedTask} + setSelectedTask={(task) => { + clearError("tasks"); + setSelectedTask(task); + }} + error={errors.tasks} + nextCursor={nextTaskCursor} + /> { diff --git a/client/src/components/TasksTab.tsx b/client/src/components/TasksTab.tsx new file mode 100644 index 000000000..33256a612 --- /dev/null +++ b/client/src/components/TasksTab.tsx @@ -0,0 +1,227 @@ +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 ; + case "input_required": + return ; + case "completed": + return ; + case "failed": + return ; + case "cancelled": + return ; + default: + return ; + } +}; + +const TasksTab = ({ + tasks, + listTasks, + clearTasks, + cancelTask, + selectedTask, + setSelectedTask, + error, + nextCursor, +}: { + tasks: Task[]; + listTasks: () => void; + clearTasks: () => void; + cancelTask: (taskId: string) => Promise; + selectedTask: Task | null; + setSelectedTask: (task: Task | null) => void; + error: string | null; + nextCursor?: string; +}) => { + const [isCancelling, setIsCancelling] = useState(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 ( + +
+
+ 0} + renderItem={(task) => ( +
+ +
+ {task.taskId} + + {task.status} -{" "} + {new Date(task.lastUpdatedAt).toLocaleString()} + +
+
+ )} + /> +
+ +
+ {error && ( + + + Error + {error} + + )} + + {displayedTask ? ( +
+
+
+

+ Task Details +

+

+ ID: {displayedTask.taskId} +

+
+ {displayedTask.status === "working" && ( + + )} +
+ +
+
+

+ Status +

+
+ + + {displayedTask.status.replace("_", " ")} + +
+
+
+

+ Last Updated +

+

+ {new Date(displayedTask.lastUpdatedAt).toLocaleString()} +

+
+
+

+ Created At +

+

+ {new Date(displayedTask.createdAt).toLocaleString()} +

+
+
+

+ TTL +

+

+ {displayedTask.ttl === null + ? "Infinite" + : `${displayedTask.ttl}s`} +

+
+
+ + {displayedTask.statusMessage && ( +
+

+ Status Message +

+

+ {displayedTask.statusMessage} +

+
+ )} + +
+

Full Task Object

+
+ +
+
+
+ ) : ( +
+
+ +

No Task Selected

+

Select a task from the list to view its details.

+ +
+
+ )} +
+
+
+ ); +}; + +export default TasksTab; diff --git a/client/src/components/ToolResults.tsx b/client/src/components/ToolResults.tsx index 8cdf38b96..38d1d0382 100644 --- a/client/src/components/ToolResults.tsx +++ b/client/src/components/ToolResults.tsx @@ -12,6 +12,7 @@ interface ToolResultsProps { selectedTool: Tool | null; resourceContent: Record; 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 ? ( Error + ) : isTaskRunning ? ( + Task Running ) : ( Success )} diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 047d327e5..d872e6299 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -64,6 +64,7 @@ const ToolsTab = ({ selectedTool, setSelectedTool, toolResult, + isPollingTask, nextCursor, error, resourceContent, @@ -76,16 +77,19 @@ const ToolsTab = ({ name: string, params: Record, metadata?: Record, + runAsTask?: boolean, ) => Promise; selectedTool: Tool | null; setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; + isPollingTask?: boolean; nextCursor: ListToolsResult["nextCursor"]; error: string | null; resourceContent: Record; onReadResource?: (uri: string) => void; }) => { const [params, setParams] = useState>({}); + 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 = ({ )} +
+ + setRunAsTask(checked) + } + /> + +