diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..39fc2812a 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(() => { @@ -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(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, toolMetadata?: Record, + 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) + ? ((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, 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) + ? ((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 +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 = () => { Tools + + + Tasks + Ping @@ -1182,10 +1456,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 +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); }} /> + { + 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/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx new file mode 100644 index 000000000..a82e13502 --- /dev/null +++ b/client/src/__tests__/App.samplingNavigation.test.tsx @@ -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; + +// 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: () =>
Sidebar
, +})); + +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) => ( +
+
sampling-request-{request.id}
+ + +
+ ), +})); + +// 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(); + + // 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(); + + 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"); + }); + }); +}); diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index c8ed9dcb1..95036d4b8 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -9,6 +9,7 @@ import SamplingRequest from "./SamplingRequest"; export type PendingRequest = { id: number; request: CreateMessageRequest; + originatingTab?: string; }; export type Props = { diff --git a/client/src/components/TasksTab.tsx b/client/src/components/TasksTab.tsx new file mode 100644 index 000000000..32ffa7d46 --- /dev/null +++ b/client/src/components/TasksTab.tsx @@ -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 ; + 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" || + displayedTask.status === "input_required") && ( + + )} +
+ +
+
+

+ 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}ms`} +

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