From dbe39f7a6d5a8bc384e6db373f88b89f178d4f9c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 15 Jan 2026 15:02:05 -0500 Subject: [PATCH 1/2] ### client/src/App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Imports: - Added: `Task` and `GetTaskResultSchema` to the same import block. - UI Icons: Added `ListTodo` from `lucide-react` to the icon imports. - Components: Added `TasksTab` import beside `ToolsTab`. - Config utils: Added `getMCPTaskTtl` to the config utils import block. - State additions: - `const [tasks, setTasks] = useState([]);` - Extended `errors` state to include a `tasks` key: `tasks: null,` - `const [selectedTask, setSelectedTask] = useState(null);` - `const [isPollingTask, setIsPollingTask] = useState(false);` - `const [nextTaskCursor, setNextTaskCursor] = useState();` - Hook: `useConnection({...})` return value usage extended - Added destructured functions: `cancelTask: cancelMcpTask` and `listTasks: listMcpTasks` from the custom hook. - Notification handling: - In `onNotification`, added: - If `method === "notifications/tasks/list_changed"`, call `listTasks()` (voided). - If `method === "notifications/tasks/status"`, treat `notification.params` as a `Task` and update `tasks` state: replace if exists by `taskId`, otherwise prepend. Also update `selectedTask` if it’s the same `taskId`. - Tab routing: - When computing valid tabs, added `tasks` when server declares capability: `...(serverCapabilities?.tasks ? ["tasks"] : []),` - When choosing a default tab, added a branch to fall back to `"tasks"` if neither resources/prompts/tools are present but tasks are. - Effect for Tasks tab: - When `mcpClient` is connected and `activeTab === "tasks"`, invoke `listTasks()`. - Tools → task-augmented calls integration in `callTool`: - Parameter signature supports `runAsTask?: boolean` (already present in this file), but now: - If `runAsTask` is true, augment the `tools/call` request’s `params` with a `task` object: `{ task: { ttl: getMCPTaskTtl(config) } }`. - Use a permissive result schema for tool call: `sendMCPRequest(request, z.any(), "tools")` to avoid version-mismatch schema issues. - Task reference detection introduced: - `isTaskResult` helper checks for a nested `task` object with `taskId` (i.e., `response.task.taskId`). - When task is detected: - Set `isPollingTask(true)`. - Immediately set a temporary `toolResult` that includes text content “Task created: … Polling for status…” and `_meta` with `"io.modelcontextprotocol/related-task": { taskId }`. - Start a polling loop: - Delay 1s between polls. - Call `tasks/get` with `GetTaskResultSchema` for status. - If status is `completed`: call `tasks/result` with `z.any()` to retrieve the final result and set it as `toolResult`; call `listTasks()`. - If status is `failed` or `cancelled`: set an error `toolResult` content that includes the status + `statusMessage`; call `listTasks()`. - Else (still running): update `toolResult` content with current `status`/`statusMessage` and preserve `_meta` related-task. - After loop, set `isPollingTask(false)`. - When not a task response, set `toolResult` directly from response (cast to `CompatibilityCallToolResult`). - Tasks list + cancel helpers in App: - `listTasks`: uses `listMcpTasks(nextTaskCursor)` from the hook, updates `tasks`, `nextTaskCursor`, and clears `errors.tasks`. - `cancelTask`: calls `cancelMcpTask(taskId)`, updates `tasks` array by `taskId`, updates `selectedTask` if it matches, and clears `errors.tasks`. - UI integration: - Added a `TabsTrigger` for “Tasks” with `` icon, disabled unless server supports tasks. - Added `` to the main `TabsContent` block, passing: `tasks`, `listTasks`, `clearTasks`, `cancelTask`, `selectedTask`, `setSelectedTask`, `error={errors.tasks}`, `nextCursor={nextTaskCursor}`. - Passed `isPollingTask={isPollingTask}` and `toolResult` into `ToolsTab` so the Tools tab can show the live “Polling Task…” state and block reruns while polling. Note: The raw diff is long; the key hunks align with the above bullet points (imports, state, notifications, tab wiring, request augmentation, polling loop, UI additions). --- ### client/src/components/ToolsTab.tsx - Props shape changed: - Added `isPollingTask?: boolean` prop in the destructured props and in the prop types. - The `callTool` callback signature is now `(name, params, metadata?, runAsTask?) => Promise` (runAsTask added earlier; test updates elsewhere reflect this). - Local state additions: - `const [runAsTask, setRunAsTask] = useState(false);` - Reset behavior: - When switching tools (`useEffect` on `selectedTool`), reset `runAsTask(false)`. - When clearing the list in `ListPane.clearItems`, also call `setRunAsTask(false)`. - UI additions: - New checkbox control block to toggle “Run as task”: - Checkbox `id="run-as-task"`, bound to `runAsTask`, with `onCheckedChange` → `setRunAsTask(checked)`. - Label “Run as task”. - Run button disabling conditions expanded to include `isPollingTask`. - Run button text shows spinner with conditional label: - If `isToolRunning || isPollingTask` → show spinner and text `isPollingTask ? "Polling Task..." : "Running..."`. - Call invocation change: - When clicking “Run Tool”, the `callTool` is invoked with `(selectedTool.name, params, metadata?, runAsTask)`. - ToolResults relay: - Passes `isPollingTask` to ``. --- ### client/src/components/ToolResults.tsx - Props shape changed: - Added optional prop: `isPollingTask?: boolean`. - Task-running banner logic: - Extracts related task from the tool result’s `_meta["io.modelcontextprotocol/related-task"]` if present. - Computes `isTaskRunning` as `isPollingTask ||` a text-heuristic against `structuredResult.content` entries that contain text like “Polling” or “Task status”. - Header “Tool Result:” now conditionally shows: - `Error` (red) if `isError` is true, else - `Task Running` (yellow) if `isTaskRunning`, else - `Success` (green). No other changes to validation or rendering of content blocks. --- ### client/src/components/TasksTab.tsx (new file) - A brand new tab to list and inspect tasks. - Key elements: - Imports `Task` type and multiple status icons. - `TaskStatusIcon` component maps task `status` to an icon and color. - Main `TasksTab` props: `tasks`, `listTasks`, `clearTasks`, `cancelTask`, `selectedTask`, `setSelectedTask`, `error`, `nextCursor`. - Left column (`ListPane`): lists tasks, shows status icon, `taskId`, `status`, and last update time; button text changes to “List More Tasks” if `nextCursor` present; disables button if no cursor and list non-empty. - Right column: - Shows error `Alert` if `error` prop provided. - If a task is selected: header with `Task Details`, a Cancel button when `status === "working"` (shows a spinner while cancelling), and a grid of task fields: Status (with colored label and icon), Last Updated, Created At, TTL (shows “Infinite” if `ttl === null`, otherwise shows numeric with `s` suffix), optional Status Message, and full task JSON via `JsonView`. - If no task is selected: centered empty state with a “Refresh Tasks” button. --- ### client/src/lib/hooks/useConnection.ts - Imports added from `@modelcontextprotocol/sdk/types.js`: - `ListTasksResultSchema`, `CancelTaskResultSchema`, `TaskStatusNotificationSchema`. - Client capabilities on `connect`: - Added `tasks: { list: {}, cancel: {} }` into the `clientCapabilities` passed to `new Client(...)`. - Notification handling setup: - The hook’s notification schema registration now includes the `TaskStatusNotificationSchema` in the `setNotificationHandler` list so the app receives `notifications/tasks/status`. - New hook functions: - `cancelTask(taskId: string)` sends `tasks/cancel` with `CancelTaskResultSchema`. - `listTasks(cursor?: string)` sends `tasks/list` with `ListTasksResultSchema`. - Exports: - Returned object now includes `cancelTask` and `listTasks`. --- ### client/src/utils/configUtils.ts - Added a new getter: - `export const getMCPTaskTtl = (config: InspectorConfig): number => { return config.MCP_TASK_TTL.value as number; };` --- ### client/src/lib/configurationTypes.ts - `InspectorConfig` type extended with a new item: - `MCP_TASK_TTL: ConfigItem;` - Includes descriptive JSDoc about default TTL in milliseconds for newly created tasks. --- ### client/src/lib/constants.ts - `DEFAULT_INSPECTOR_CONFIG` extended with a default for task TTL: - Key: `MCP_TASK_TTL` - Label: `"Task TTL"` - Description: `"Default Time-to-Live (TTL) in milliseconds for newly created tasks"` - Default `value: 60000` - `is_session_item: false` --- ### client/src/components/__tests__/ToolsTab.test.tsx - Expectations updated due to new `callTool` signature (4th arg `runAsTask`). Everywhere the test asserts a `callTool` invocation, an additional trailing `false` argument was added to reflect the default state when the box isn’t checked. - Examples of added trailing `false` at various assertion points (line offsets from diff): after calls around prior lines 132, 157, 193, 236, 257, 279, 297, 818, 1082 (now passing 4 arguments: name, params, metadata-or-undefined, false). --- ### Additional notes - The Tasks feature is wired end-to-end: - Client capability declaration (list/cancel) - Notification handler for `notifications/tasks/status` - Tools call augmentation with `task` `{ ttl }` - Polling loop using `tasks/get` and `tasks/result` - UI feedback in Tools and dedicated Tasks tab - Configurable TTL via new config item and getter --- client/src/App.tsx | 269 +++++++++++++++++- client/src/components/TasksTab.tsx | 222 +++++++++++++++ client/src/components/ToolResults.tsx | 17 ++ client/src/components/ToolsTab.tsx | 28 +- .../components/__tests__/ToolsTab.test.tsx | 15 +- client/src/lib/configurationTypes.ts | 5 + client/src/lib/constants.ts | 7 + client/src/lib/hooks/useConnection.ts | 30 ++ client/src/utils/configUtils.ts | 3 + package-lock.json | 115 +++----- 10 files changed, 617 insertions(+), 94 deletions(-) create mode 100644 client/src/components/TasksTab.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..0f70886c7 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(() => { @@ -305,6 +315,8 @@ const App = () => { requestHistory, clearRequestHistory, makeRequest, + cancelTask: cancelMcpTask, + listTasks: listMcpTasks, sendNotification, handleCompletion, completionsSupported, @@ -324,6 +336,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 (selectedTask?.taskId === task.taskId) { + setSelectedTask(task); + } + } }, onPendingRequest: (request, resolve, reject) => { setPendingSampleRequests((prev) => [ @@ -367,6 +398,7 @@ const App = () => { ...(serverCapabilities?.resources ? ["resources"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tasks ? ["tasks"] : []), "ping", "sampling", "elicitations", @@ -383,7 +415,9 @@ const App = () => { ? "prompts" : serverCapabilities?.tools ? "tools" - : "ping"; + : serverCapabilities?.tasks + ? "tasks" + : "ping"; setActiveTab(defaultTab); window.location.hash = defaultTab; @@ -391,6 +425,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 +651,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 +709,7 @@ const App = () => { ...(serverCapabilities?.resources ? ["resources"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tasks ? ["tasks"] : []), "ping", "sampling", "elicitations", @@ -841,6 +885,7 @@ const App = () => { name: string, params: Record, toolMetadata?: Record, + runAsTask?: boolean, ) => { lastToolCallOriginTabRef.current = currentTabRef.current; @@ -859,20 +904,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 +1077,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 +1251,13 @@ const App = () => { Tools + + + Tasks + Ping @@ -1182,10 +1406,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 +1419,7 @@ const App = () => { setToolResult(null); }} toolResult={toolResult} + isPollingTask={isPollingTask} nextCursor={nextToolCursor} error={errors.tools} resourceContent={resourceContentMap} @@ -1202,6 +1428,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..1e2db54fa --- /dev/null +++ b/client/src/components/TasksTab.tsx @@ -0,0 +1,222 @@ +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 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} + + )} + + {selectedTask ? ( +
+
+
+

+ Task Details +

+

+ ID: {selectedTask.taskId} +

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

+ Status +

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

+ Last Updated +

+

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

+
+
+

+ Created At +

+

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

+
+
+

+ TTL +

+

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

+
+
+ + {selectedTask.statusMessage && ( +
+

+ Status Message +

+

+ {selectedTask.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) + } + /> + +