Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,9 @@ const App = () => {
error={errors.prompts}
/>
<ToolsTab
serverSupportsTaskRequests={
!!serverCapabilities?.tasks?.requests?.tools?.call
}
tools={tools}
listTools={() => {
clearError("tools");
Expand Down
83 changes: 62 additions & 21 deletions client/src/components/ToolsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,38 @@ import {
isReservedMetaKey,
} from "@/utils/metaUtils";

/**
* Extended Tool type that includes optional fields used by the inspector.
*/
export interface ExtendedTool extends Tool, WithIcons {
_meta?: Record<string, unknown>;
execution?: {
taskSupport?: "forbidden" | "required" | "optional";
};
}

// Type guard to safely detect the optional _meta field without using `any`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
typeof (tool as { _meta?: unknown })._meta !== "undefined";
const hasMeta = (
tool: Tool,
): tool is ExtendedTool & { _meta: Record<string, unknown> } =>
typeof (tool as ExtendedTool)._meta !== "undefined";

// Returns the execution.taskSupport value for a tool, defaulting to "forbidden" per MCP spec
const getTaskSupport = (
tool: Tool | null,
): "forbidden" | "required" | "optional" => {
if (!tool) return "forbidden";
const extendedTool = tool as ExtendedTool;
const taskSupport = extendedTool.execution?.taskSupport;
if (
taskSupport === "forbidden" ||
taskSupport === "required" ||
taskSupport === "optional"
) {
return taskSupport;
}
return "forbidden";
};

// Type guard to safely detect the optional annotations field
const hasAnnotations = (
Expand Down Expand Up @@ -148,6 +177,7 @@ const ToolsTab = ({
error,
resourceContent,
onReadResource,
serverSupportsTaskRequests,
}: {
tools: Tool[];
listTools: () => void;
Expand All @@ -166,6 +196,7 @@ const ToolsTab = ({
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
serverSupportsTaskRequests: boolean;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [runAsTask, setRunAsTask] = useState(false);
Expand Down Expand Up @@ -210,14 +241,17 @@ const ToolsTab = ({
];
});
setParams(Object.fromEntries(params));
setRunAsTask(false);
const toolTaskSupport = serverSupportsTaskRequests
? getTaskSupport(selectedTool)
: "forbidden";
setRunAsTask(toolTaskSupport === "required");

// Reset validation errors when switching tools
setHasValidationErrors(false);

// Clear form refs for the previous tool
formRefs.current = {};
}, [selectedTool]);
}, [selectedTool, serverSupportsTaskRequests]);

const hasReservedMetadataEntry = metadataEntries.some(({ key }) => {
const trimmedKey = key.trim();
Expand All @@ -234,6 +268,10 @@ const ToolsTab = ({
return trimmedKey !== "" && !hasValidMetaName(trimmedKey);
});

const taskSupport = serverSupportsTaskRequests
? getTaskSupport(selectedTool)
: "forbidden";

return (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
Expand All @@ -249,7 +287,7 @@ const ToolsTab = ({
renderItem={(tool) => (
<div className="flex items-start w-full gap-2">
<div className="flex-shrink-0 mt-1">
<IconDisplay icons={(tool as WithIcons).icons} size="sm" />
<IconDisplay icons={(tool as ExtendedTool).icons} size="sm" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{tool.title || tool.name}</span>
Expand All @@ -270,7 +308,7 @@ const ToolsTab = ({
<div className="flex items-center gap-2">
{selectedTool && (
<IconDisplay
icons={(selectedTool as WithIcons).icons}
icons={(selectedTool as ExtendedTool).icons}
size="md"
/>
)}
Expand Down Expand Up @@ -747,21 +785,24 @@ 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>
{taskSupport !== "forbidden" && (
<div className="flex items-center space-x-2">
<Checkbox
id="run-as-task"
checked={runAsTask}
onCheckedChange={(checked: boolean) =>
setRunAsTask(checked)
}
disabled={taskSupport === "required"}
/>
<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
Expand Down
83 changes: 82 additions & 1 deletion client/src/components/__tests__/ToolsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import ToolsTab from "../ToolsTab";
import ToolsTab, { ExtendedTool } from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "../ui/tabs";
import { cacheToolOutputSchemas } from "../../utils/schemaUtils";
Expand Down Expand Up @@ -73,6 +73,7 @@ describe("ToolsTab", () => {
error: null,
resourceContent: {},
onReadResource: jest.fn(),
serverSupportsTaskRequests: true,
};

const renderToolsTab = (props = {}) => {
Expand Down Expand Up @@ -107,6 +108,86 @@ describe("ToolsTab", () => {
expect(newInput.value).toBe("");
});

it("should show/hide/disable run-as-task checkbox based on taskSupport", async () => {
const forbiddenTool: ExtendedTool = {
...mockTools[0],
name: "forbiddenTool",
execution: { taskSupport: "forbidden" },
};
const requiredTool: ExtendedTool = {
...mockTools[0],
name: "requiredTool",
execution: { taskSupport: "required" },
};
const optionalTool: ExtendedTool = {
...mockTools[0],
name: "optionalTool",
execution: { taskSupport: "optional" },
};

const { rerender } = renderToolsTab({
selectedTool: forbiddenTool,
});

expect(screen.queryByLabelText(/run as task/i)).not.toBeInTheDocument();

rerender(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} selectedTool={optionalTool} />
</Tabs>,
);
const optionalCheckbox = screen.getByLabelText(
/run as task/i,
) as HTMLInputElement;
expect(optionalCheckbox).toBeInTheDocument();
expect(optionalCheckbox.getAttribute("aria-checked")).toBe("false");
expect(optionalCheckbox).not.toBeDisabled();

rerender(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} selectedTool={requiredTool} />
</Tabs>,
);
const requiredCheckbox = screen.getByLabelText(
/run as task/i,
) as HTMLInputElement;
expect(requiredCheckbox).toBeInTheDocument();
expect(requiredCheckbox.getAttribute("aria-checked")).toBe("true");
expect(requiredCheckbox).toBeDisabled();
});

it("should hide run-as-task checkbox when serverSupportsTaskRequests is false even for required/optional tools", async () => {
const requiredTool: ExtendedTool = {
...mockTools[0],
name: "requiredTool",
execution: { taskSupport: "required" },
};
const optionalTool: ExtendedTool = {
...mockTools[0],
name: "optionalTool",
execution: { taskSupport: "optional" },
};

const { rerender } = renderToolsTab({
selectedTool: requiredTool,
serverSupportsTaskRequests: false,
});

expect(screen.queryByLabelText(/run as task/i)).not.toBeInTheDocument();

rerender(
<Tabs defaultValue="tools">
<ToolsTab
{...defaultProps}
selectedTool={optionalTool}
serverSupportsTaskRequests={false}
/>
</Tabs>,
);

expect(screen.queryByLabelText(/run as task/i)).not.toBeInTheDocument();
});

it("should handle integer type inputs", async () => {
renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type
Expand Down
Loading