Merge branch 'main' into web-client-oauth-proxy-fetch

This commit is contained in:
Cliff Hall 2026-03-18 18:41:05 -04:00 • committed by GitHub
commit 26c9f60d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 4329 additions and 358 deletions

View file

@ -30,6 +30,7 @@ import {
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { createProxyFetch } from "./lib/proxyFetch";
@ -52,6 +53,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
AppWindow,
Bell,
Files,
FolderTree,
@ -76,6 +78,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import TasksTab from "./components/TasksTab";
import AppsTab from "./components/AppsTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
@ -100,6 +103,27 @@ import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
type PrefilledAppsToolCall = {
id: number;
toolName: string;
params: Record<string, unknown>;
result: CompatibilityCallToolResult;
};
const hasAppResourceUri = (tool: Tool): boolean => {
return Boolean(getToolUiResourceUri(tool));
};
const cloneToolParams = (
source: Record<string, unknown>,
): Record<string, unknown> => {
try {
return structuredClone(source);
} catch {
return { ...source };
}
};
const filterReservedMetadata = (
metadata: Record<string, string>,
): Record<string, string> => {
@ -127,12 +151,17 @@ const App = () => {
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
new Set(),
);
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 [prefilledAppsToolCall, setPrefilledAppsToolCall] =
useState<PrefilledAppsToolCall | null>(null);
const [errors, setErrors] = useState<Record<string, string | null>>({
resources: null,
prompts: null,
@ -287,6 +316,7 @@ const App = () => {
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const prefilledAppsToolCallIdRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
const hash = window.location.hash.slice(1);
@ -309,11 +339,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
if (!validTabs.includes(originatingTab)) return;
@ -441,11 +473,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
const isValidTab = validTabs.includes(hash);
@ -474,6 +508,13 @@ const App = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab]);
useEffect(() => {
if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) {
void listTools();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab, serverCapabilities?.tools]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@ -766,11 +807,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping",
"sampling",
"elicitations",
"roots",
"auth",
"metadata",
];
if (validTabs.includes(originatingTab)) {
@ -860,22 +903,48 @@ const App = () => {
};
const readResource = async (uri: string) => {
if (fetchingResources.has(uri) || resourceContentMap[uri]) {
return;
}
console.log("[App] Reading resource:", uri);
setFetchingResources((prev) => new Set(prev).add(uri));
lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest(
{
method: "resources/read" as const,
params: { uri },
},
ReadResourceResultSchema,
"resources",
);
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
try {
const response = await sendMCPRequest(
{
method: "resources/read" as const,
params: { uri },
},
ReadResourceResultSchema,
"resources",
);
console.log("[App] Resource read response:", {
uri,
responseLength: JSON.stringify(response).length,
hasContents: !!(response as { contents?: unknown[] }).contents,
});
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
} catch (error) {
console.error(`[App] Failed to read resource ${uri}:`, error);
const errorString = (error as Error).message ?? String(error);
setResourceContentMap((prev) => ({
...prev,
[uri]: JSON.stringify({ error: errorString }),
}));
} finally {
setFetchingResources((prev) => {
const next = new Set(prev);
next.delete(uri);
return next;
});
}
};
const subscribeToResource = async (uri: string) => {
@ -942,7 +1011,7 @@ const App = () => {
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
): Promise<CompatibilityCallToolResult> => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
@ -1010,7 +1079,7 @@ const App = () => {
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
: undefined;
setToolResult({
let latestToolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
@ -1021,7 +1090,8 @@ const App = () => {
...(initialResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
};
setToolResult(latestToolResult);
// Polling loop
let taskCompleted = false;
@ -1058,12 +1128,13 @@ const App = () => {
CompatibilityCallToolResultSchema,
);
console.log(`Result received for task ${taskId}:`, result);
setToolResult(result as CompatibilityCallToolResult);
latestToolResult = result as CompatibilityCallToolResult;
setToolResult(latestToolResult);
// Refresh tasks list to show completed state
void listTasks();
} else {
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1071,7 +1142,8 @@ const App = () => {
},
],
isError: true,
});
};
setToolResult(latestToolResult);
// Refresh tasks list to show failed/cancelled state
void listTasks();
}
@ -1085,7 +1157,7 @@ const App = () => {
? ((response as { _meta?: Record<string, unknown> })._meta ??
{})
: undefined;
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1096,13 +1168,14 @@ const App = () => {
...(pollingResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
};
setToolResult(latestToolResult);
// Refresh tasks list to show progress
void listTasks();
}
} catch (pollingError) {
console.error("Error polling task status:", pollingError);
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1110,16 +1183,22 @@ const App = () => {
},
],
isError: true,
});
};
setToolResult(latestToolResult);
taskCompleted = true;
}
}
setIsPollingTask(false);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
return latestToolResult;
} else {
setToolResult(response as CompatibilityCallToolResult);
const directResult = response as CompatibilityCallToolResult;
setToolResult(directResult);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
return directResult;
}
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
@ -1133,6 +1212,7 @@ const App = () => {
setToolResult(toolResult);
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
return toolResult;
}
};
@ -1319,6 +1399,10 @@ const App = () => {
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="apps">
<AppWindow className="w-4 h-4 mr-2" />
Apps
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
@ -1453,6 +1537,9 @@ const App = () => {
error={errors.prompts}
/>
<ToolsTab
serverSupportsTaskRequests={
!!serverCapabilities?.tasks?.requests?.tools?.call
}
tools={tools}
listTools={() => {
clearError("tools");
@ -1471,7 +1558,26 @@ const App = () => {
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params, metadata, runAsTask);
const result = await callTool(
name,
params,
metadata,
runAsTask,
);
const calledTool = tools.find(
(tool) => tool.name === name,
);
if (calledTool && hasAppResourceUri(calledTool)) {
setPrefilledAppsToolCall({
id: ++prefilledAppsToolCallIdRef.current,
toolName: name,
params: cloneToolParams(params),
result,
});
} else {
setPrefilledAppsToolCall(null);
}
return result;
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1508,6 +1614,35 @@ const App = () => {
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<AppsTab
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
clearError("tools");
setToolResult(null);
return callTool(name, params, metadata, runAsTask);
}}
prefilledToolCall={prefilledAppsToolCall}
onPrefilledToolCallConsumed={(callId) => {
setPrefilledAppsToolCall((prev) =>
prev?.id === callId ? null : prev,
);
}}
error={errors.tools}
mcpClient={mcpClient}
onNotification={(notification) => {
setNotifications((prev) => [...prev, notification]);
}}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {

View file

@ -0,0 +1,284 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
type ToolListEntry = {
name: string;
inputSchema: {
type: "object";
properties: Record<string, unknown>;
};
_meta: Record<string, unknown>;
};
type AppsTabProps = {
tools: ToolListEntry[];
prefilledToolCall?: {
id: number;
toolName: string;
params: Record<string, unknown>;
result: {
content: Array<{ type: string; text: string }>;
};
} | null;
onPrefilledToolCallConsumed?: (callId: number) => void;
};
// 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("../components/ResourcesTab", () => ({
__esModule: true,
default: () => <div>ResourcesTab</div>,
}));
jest.mock("../components/PromptsTab", () => ({
__esModule: true,
default: () => <div>PromptsTab</div>,
}));
jest.mock("../components/TasksTab", () => ({
__esModule: true,
default: () => <div>TasksTab</div>,
}));
jest.mock("../components/ConsoleTab", () => ({
__esModule: true,
default: () => <div>ConsoleTab</div>,
}));
jest.mock("../components/PingTab", () => ({
__esModule: true,
default: () => <div>PingTab</div>,
}));
jest.mock("../components/SamplingTab", () => ({
__esModule: true,
default: () => <div>SamplingTab</div>,
}));
jest.mock("../components/RootsTab", () => ({
__esModule: true,
default: () => <div>RootsTab</div>,
}));
jest.mock("../components/ElicitationTab", () => ({
__esModule: true,
default: () => <div>ElicitationTab</div>,
}));
jest.mock("../components/MetadataTab", () => ({
__esModule: true,
default: () => <div>MetadataTab</div>,
}));
jest.mock("../components/AuthDebugger", () => ({
__esModule: true,
default: () => <div>AuthDebugger</div>,
}));
jest.mock("../components/HistoryAndNotifications", () => ({
__esModule: true,
default: () => <div>HistoryAndNotifications</div>,
}));
jest.mock("../components/ToolsTab", () => ({
__esModule: true,
default: ({
listTools,
callTool,
}: {
listTools: () => void;
callTool: (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<unknown>;
}) => (
<div data-testid="tools-tab">
<button type="button" onClick={listTools}>
mock list tools
</button>
<button
type="button"
onClick={() => {
void callTool("weatherApp", { city: "Lisbon" });
}}
>
mock run app tool
</button>
</div>
),
}));
jest.mock("../components/AppsTab", () => ({
__esModule: true,
default: (props: AppsTabProps) => {
const prefilled =
props && "prefilledToolCall" in props ? props.prefilledToolCall : null;
const tools = props && "tools" in props ? props.tools : [];
return (
<div data-testid="apps-tab">
<div data-testid="apps-tools">{JSON.stringify(tools)}</div>
<div data-testid="apps-prefilled">
{JSON.stringify(prefilled ?? null)}
</div>
{prefilled && (
<button
type="button"
onClick={() => props?.onPrefilledToolCallConsumed?.(prefilled.id)}
>
consume prefilled
</button>
)}
</div>
);
},
}));
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: jest.fn(),
}));
describe("App - Tools to Apps prefilled handoff", () => {
const mockUseConnection = jest.mocked(useConnection);
beforeEach(() => {
jest.clearAllMocks();
window.location.hash = "#tools";
});
it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => {
const makeRequest = jest.fn(async (request: { method: string }) => {
if (request.method === "tools/list") {
return {
tools: [
{
name: "weatherApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: {
"ui/resourceUri": "ui://weather-app",
},
},
],
nextCursor: undefined,
};
}
if (request.method === "tools/call") {
return {
content: [{ type: "text", text: "weather result" }],
};
}
throw new Error(`Unexpected method: ${request.method}`);
});
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: { tools: { listChanged: true } },
serverImplementation: null,
mcpClient: {
request: jest.fn(),
notification: jest.fn(),
close: jest.fn(),
} as unknown as Client,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest,
cancelTask: jest.fn(),
listTasks: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
} as ReturnType<typeof useConnection>);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /mock list tools/i }));
await waitFor(() => {
const tools = JSON.parse(
screen.getByTestId("apps-tools").textContent || "[]",
);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("weatherApp");
});
fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i }));
await waitFor(() => {
const prefilled = JSON.parse(
screen.getByTestId("apps-prefilled").textContent || "null",
);
expect(prefilled.toolName).toBe("weatherApp");
expect(prefilled.params).toEqual({ city: "Lisbon" });
expect(prefilled.result.content[0].text).toBe("weather result");
});
fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i }));
await waitFor(() => {
expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null");
});
});
});

View file

@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
Tool,
ContentBlock,
CompatibilityCallToolResult,
CallToolResult,
CallToolResultSchema,
ServerNotification,
LoggingMessageNotificationParams,
} from "@modelcontextprotocol/sdk/types.js";
import {
AppRenderer as McpUiAppRenderer,
type McpUiHostContext,
type RequestHandlerExtra,
} from "@mcp-ui/client";
import {
type McpUiMessageRequest,
type McpUiMessageResult,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { useToast } from "@/lib/hooks/useToast";
interface AppRendererProps {
sandboxPath: string;
tool: Tool;
mcpClient: Client | null;
toolInput?: Record<string, unknown>;
toolResult?: CompatibilityCallToolResult | null;
onNotification?: (notification: ServerNotification) => void;
}
const AppRenderer = ({
sandboxPath,
tool,
mcpClient,
toolInput,
toolResult,
onNotification,
}: AppRendererProps) => {
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const normalizedToolResult = useMemo<CallToolResult | undefined>(() => {
if (!toolResult) {
return undefined;
}
if ("content" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(toolResult);
return parsedResult.success ? parsedResult.data : undefined;
}
if ("toolResult" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(
toolResult.toolResult,
);
return parsedResult.success ? parsedResult.data : undefined;
}
return undefined;
}, [toolResult]);
const hostContext: McpUiHostContext = useMemo(
() => ({
theme: document.documentElement.classList.contains("dark")
? "dark"
: "light",
}),
[],
);
const handleOpenLink = async ({ url }: { url: string }) => {
let isError = true;
if (url.startsWith("https://") || url.startsWith("http://")) {
window.open(url, "_blank");
isError = false;
}
return { isError };
};
const handleMessage = async (
params: McpUiMessageRequest["params"],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_extra: RequestHandlerExtra,
): Promise<McpUiMessageResult> => {
const message = params.content
.filter((block): block is ContentBlock & { type: "text" } =>
Boolean(block.type === "text"),
)
.map((block) => block.text)
.join("\n");
if (message) {
toast({
description: message,
});
}
return {};
};
const handleLoggingMessage = (params: LoggingMessageNotificationParams) => {
if (onNotification) {
onNotification({
method: "notifications/message",
params,
} as ServerNotification);
}
};
if (!mcpClient) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>Waiting for MCP client...</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col h-full">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div
className="flex-1 border rounded overflow-hidden"
style={{ minHeight: "400px" }}
>
<McpUiAppRenderer
client={mcpClient}
onOpenLink={handleOpenLink}
onMessage={handleMessage}
onLoggingMessage={handleLoggingMessage}
toolName={tool.name}
hostContext={hostContext}
toolInput={toolInput}
toolResult={normalizedToolResult}
sandbox={{
url: new URL(sandboxPath, window.location.origin),
}}
onError={(err) => setError(err.message)}
/>
</div>
</div>
);
};
export default AppRenderer;

View file

@ -0,0 +1,677 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertCircle,
X,
Play,
Loader2,
ChevronRight,
Maximize2,
Minimize2,
} from "lucide-react";
import {
Tool,
ServerNotification,
CompatibilityCallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
import AppRenderer from "./AppRenderer";
import ListPane from "./ListPane";
import IconDisplay, { WithIcons } from "./IconDisplay";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import {
generateDefaultValue,
isPropertyRequired,
normalizeUnionType,
resolveRef,
} from "@/utils/schemaUtils";
interface AppsTabProps {
sandboxPath: string;
tools: Tool[];
listTools: () => void;
callTool: (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<CompatibilityCallToolResult>;
prefilledToolCall?: {
id: number;
toolName: string;
params: Record<string, unknown>;
result: CompatibilityCallToolResult;
} | null;
onPrefilledToolCallConsumed?: (callId: number) => void;
error: string | null;
mcpClient: Client | null;
onNotification?: (notification: ServerNotification) => void;
}
// Type guard to check if a tool has UI metadata
const hasUIMetadata = (tool: Tool): boolean => {
return !!getToolUiResourceUri(tool);
};
const cloneToolParams = (
source: Record<string, unknown>,
): Record<string, unknown> => {
try {
return structuredClone(source);
} catch {
return { ...source };
}
};
const AppsTab = ({
sandboxPath,
tools,
listTools,
callTool,
prefilledToolCall,
onPrefilledToolCallConsumed,
error,
mcpClient,
onNotification,
}: AppsTabProps) => {
const [appTools, setAppTools] = useState<Tool[]>([]);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [params, setParams] = useState<Record<string, unknown>>({});
const [isAppOpen, setIsAppOpen] = useState(false);
const [isOpeningApp, setIsOpeningApp] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const [submittedParams, setSubmittedParams] = useState<
Record<string, unknown> | undefined
>(undefined);
const [submittedToolResult, setSubmittedToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const openAppRunIdRef = useRef(0);
const prefillingParamsRef = useRef<Record<string, unknown> | null>(null);
const consumedPrefilledCallIdRef = useRef<number | null>(null);
const buildInitialParams = useCallback((tool: Tool) => {
const initialParams = Object.entries(tool.inputSchema.properties ?? []).map(
([key, value]) => {
const resolvedValue = resolveRef(
value as JsonSchemaType,
tool.inputSchema as JsonSchemaType,
);
return [
key,
generateDefaultValue(
resolvedValue,
key,
tool.inputSchema as JsonSchemaType,
),
];
},
);
return Object.fromEntries(initialParams);
}, []);
// Function to check if any form has validation errors
const checkValidationErrors = useCallback(() => {
const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid,
);
setHasValidationErrors(errors);
return errors;
}, []);
// Filter tools that have UI metadata
useEffect(() => {
const filtered = tools.filter(hasUIMetadata);
console.log("[AppsTab] Filtered app tools:", {
totalTools: tools.length,
appTools: filtered.length,
appToolNames: filtered.map((t) => t.name),
});
setAppTools(filtered);
// If current selected tool is no longer available, reset selection
if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) {
setSelectedTool(null);
setIsAppOpen(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}
}, [tools, selectedTool]);
useEffect(() => {
if (selectedTool) {
const prefillingParams = prefillingParamsRef.current;
if (prefillingParams) {
setParams(prefillingParams);
prefillingParamsRef.current = null;
} else {
setParams(buildInitialParams(selectedTool));
}
setHasValidationErrors(false);
formRefs.current = {};
} else {
setParams({});
setIsAppOpen(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}
}, [buildInitialParams, selectedTool]);
useEffect(() => {
if (!prefilledToolCall) {
return;
}
if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) {
return;
}
const matchingTool = appTools.find(
(tool) => tool.name === prefilledToolCall.toolName,
);
if (!matchingTool) {
return;
}
const hydratedParams = cloneToolParams(prefilledToolCall.params);
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = hydratedParams;
setSelectedTool(matchingTool);
setSubmittedParams(hydratedParams);
setSubmittedToolResult(prefilledToolCall.result);
setIsAppOpen(true);
setIsMaximized(false);
consumedPrefilledCallIdRef.current = prefilledToolCall.id;
onPrefilledToolCallConsumed?.(prefilledToolCall.id);
}, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]);
const handleRefresh = useCallback(() => {
listTools();
}, [listTools]);
const executeToolAndOpenApp = useCallback(
async (tool: Tool, toolParams: Record<string, unknown>) => {
const runId = ++openAppRunIdRef.current;
const runParams = cloneToolParams(toolParams);
prefillingParamsRef.current = null;
setIsOpeningApp(true);
setSubmittedParams(runParams);
setSubmittedToolResult(null);
try {
const result = await callTool(tool.name, runParams);
if (runId !== openAppRunIdRef.current) {
return;
}
setSubmittedParams(runParams);
setSubmittedToolResult(result);
setIsAppOpen(true);
} catch {
if (runId !== openAppRunIdRef.current) {
return;
}
setSubmittedToolResult(null);
setIsAppOpen(false);
} finally {
if (runId === openAppRunIdRef.current) {
setIsOpeningApp(false);
}
}
},
[callTool],
);
const handleCloseApp = useCallback(() => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
setIsAppOpen(false);
setSubmittedToolResult(null);
}, []);
const handleOpenApp = useCallback(async () => {
if (!selectedTool || checkValidationErrors()) {
return;
}
await executeToolAndOpenApp(selectedTool, params);
}, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]);
const handleSelectTool = useCallback(
(tool: Tool) => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = null;
setSelectedTool(tool);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
const hasFields =
tool.inputSchema.properties &&
Object.keys(tool.inputSchema.properties).length > 0;
if (hasFields) {
setIsAppOpen(false);
return;
}
const initialParams = buildInitialParams(tool);
void executeToolAndOpenApp(tool, initialParams);
},
[buildInitialParams, executeToolAndOpenApp],
);
const handleDeselectTool = useCallback(() => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = null;
setSelectedTool(null);
setIsAppOpen(false);
setIsMaximized(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}, []);
return (
<TabsContent value="apps" className="space-y-4">
<div
className={
isMaximized
? "grid grid-cols-1 gap-4"
: "grid grid-cols-1 md:grid-cols-2 gap-4"
}
>
{!isMaximized && (
<ListPane
items={appTools}
listItems={handleRefresh}
setSelectedItem={handleSelectTool}
renderItem={(tool) => {
return (
<div className="flex items-start w-full gap-2">
<div className="flex-shrink-0 mt-1">
<IconDisplay icons={(tool as WithIcons).icons} size="sm" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate font-semibold">{tool.name}</span>
{tool.description && (
<span className="text-sm text-gray-500 text-left line-clamp-2">
{tool.description}
</span>
)}
</div>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400 mt-1" />
</div>
);
}}
title="MCP Apps"
buttonText="Refresh Apps"
/>
)}
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{selectedTool && (
<IconDisplay
icons={(selectedTool as WithIcons).icons}
size="md"
/>
)}
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select an app"}
</h3>
</div>
<div className="flex items-center gap-2">
{selectedTool && isAppOpen && (
<Button
onClick={() => setIsMaximized(!isMaximized)}
variant="ghost"
size="sm"
aria-label={isMaximized ? "Minimize" : "Maximize"}
>
{isMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</Button>
)}
{selectedTool && (
<Button
onClick={handleDeselectTool}
variant="ghost"
size="sm"
aria-label="Close app"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
<div className="p-4">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{selectedTool ? (
(() => {
const hasFields =
selectedTool.inputSchema.properties &&
Object.keys(selectedTool.inputSchema.properties).length > 0;
return (
<div className="space-y-4">
{!isAppOpen ? (
<div className="space-y-4">
{selectedTool.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{selectedTool.description}
</p>
)}
<div className="space-y-4 border rounded-lg p-4 bg-muted/30">
<h4 className="font-medium text-sm">App Input</h4>
{Object.entries(
selectedTool.inputSchema.properties ?? [],
).map(([key, value]) => {
// First resolve any $ref references
const resolvedValue = resolveRef(
value as JsonSchemaType,
selectedTool.inputSchema as JsonSchemaType,
);
const prop = normalizeUnionType(resolvedValue);
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(
key,
inputSchema,
);
return (
<div key={key} className="space-y-2">
<div className="flex justify-between">
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">
*
</span>
)}
</Label>
{prop.nullable ? (
<div className="flex items-center space-x-2">
<Checkbox
id={`${key}-null`}
checked={params[key] === null}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked
? null
: prop.type === "array"
? undefined
: prop.default !== null
? prop.default
: prop.type === "boolean"
? false
: prop.type === "string"
? ""
: undefined,
})
}
/>
<label
htmlFor={`${key}-null`}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
null
</label>
</div>
) : null}
</div>
<div
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2">
<Checkbox
id={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description ||
"Toggle this option"}
</label>
</div>
) : prop.type === "string" && prop.enum ? (
<Select
value={
params[key] === undefined
? ""
: String(params[key])
}
onValueChange={(value) => {
setParams({
...params,
[key]:
value === "" ? undefined : value,
});
}}
>
<SelectTrigger id={key}>
<SelectValue
placeholder={
prop.description ||
"Select an option"
}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((option) => (
<SelectItem
key={option}
value={option}
>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
) : prop.type === "string" ? (
<Textarea
id={key}
placeholder={prop.description}
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
setParams({
...params,
[key]:
e.target.value === ""
? undefined
: e.target.value,
});
}}
/>
) : prop.type === "object" ||
prop.type === "array" ? (
<DynamicJsonForm
ref={(ref) =>
(formRefs.current[key] = ref)
}
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
setTimeout(checkValidationErrors, 100);
}}
/>
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type="number"
id={key}
placeholder={prop.description}
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
setParams({
...params,
[key]: undefined,
});
} else {
const num = Number(value);
setParams({
...params,
[key]: isNaN(num) ? value : num,
});
}
}}
/>
) : (
<DynamicJsonForm
ref={(ref) =>
(formRefs.current[key] = ref)
}
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
setTimeout(checkValidationErrors, 100);
}}
/>
)}
</div>
</div>
);
})}
<Button
onClick={() => void handleOpenApp()}
className="w-full"
disabled={hasValidationErrors || isOpeningApp}
>
{isOpeningApp ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
{isOpeningApp ? "Opening App..." : "Open App"}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{hasFields && (
<div className="flex justify-end">
<Button
onClick={handleCloseApp}
variant="outline"
size="sm"
>
Back to Input
</Button>
</div>
)}
<div className="h-[600px]">
<AppRenderer
sandboxPath={sandboxPath}
tool={selectedTool}
mcpClient={mcpClient}
toolInput={submittedParams}
toolResult={submittedToolResult}
onNotification={onNotification}
/>
</div>
</div>
)}
</div>
);
})()
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground space-y-4">
<AlertCircle className="w-12 h-12 opacity-20" />
<p>Select an app from the list to get started</p>
{appTools.length === 0 && (
<p className="text-xs text-center max-w-[200px]">
No MCP Apps available. Apps are tools that include a{" "}
<code className="bg-muted px-1 rounded">
_meta.ui.resourceUri
</code>
</p>
)}
</div>
)}
</div>
</div>
</div>
</TabsContent>
);
};
export default AppsTab;

View file

@ -125,6 +125,9 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
const [numericInputDrafts, setNumericInputDrafts] = useState<
Record<string, string>
>({});
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
@ -134,6 +137,37 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
return !!jsonError;
};
const getPathKey = (path: string[]) =>
path.length === 0 ? "$root" : path.join(".");
const getNumericDisplayValue = (
path: string[],
currentValue: JsonValue,
): string => {
const pathKey = getPathKey(path);
if (Object.prototype.hasOwnProperty.call(numericInputDrafts, pathKey)) {
return numericInputDrafts[pathKey];
}
return typeof currentValue === "number" ? currentValue.toString() : "";
};
const updateNumericDraft = (path: string[], draftValue: string) => {
const pathKey = getPathKey(path);
setNumericInputDrafts((prev) => ({ ...prev, [pathKey]: draftValue }));
};
const clearNumericDraft = (path: string[]) => {
const pathKey = getPathKey(path);
setNumericInputDrafts((prev) => {
if (!Object.prototype.hasOwnProperty.call(prev, pathKey)) {
return prev;
}
const next = { ...prev };
delete next[pathKey];
return next;
});
};
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
@ -429,9 +463,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
value={getNumericDisplayValue(path, currentValue)}
onChange={(e) => {
const val = e.target.value;
updateNumericDraft(path, val);
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
@ -441,6 +476,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}
}
}}
onBlur={(e) => {
const val = e.target.value;
if (!val) {
clearNumericDraft(path);
return;
}
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
clearNumericDraft(path);
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
@ -453,9 +501,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
<Input
type="number"
step="1"
value={(currentValue as number)?.toString() ?? ""}
value={getNumericDisplayValue(path, currentValue)}
onChange={(e) => {
const val = e.target.value;
updateNumericDraft(path, val);
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
@ -465,6 +514,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}
}
}}
onBlur={(e) => {
const val = e.target.value;
if (!val) {
clearNumericDraft(path);
return;
}
const num = Number(val);
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
clearNumericDraft(path);
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}

View file

@ -6,7 +6,7 @@ import { useState, useMemo, useRef } from "react";
type ListPaneProps<T> = {
items: T[];
listItems: () => void;
clearItems: () => void;
clearItems?: () => void;
setSelectedItem: (item: T) => void;
renderItem: (item: T) => React.ReactNode;
title: string;
@ -101,14 +101,16 @@ const ListPane = <T extends object>({
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
{clearItems && (
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
)}
<div className="space-y-2 overflow-y-auto max-h-96">
{filteredItems.map((item, index) => (
<div

View file

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { validateRedirectUrl } from "@/utils/urlValidation";
import { useToast } from "@/lib/hooks/useToast";
import { getAuthorizationServerMetadataDiscoveryUrl } from "@/utils/oauthUtils";
interface OAuthStepProps {
label: string;
@ -81,6 +82,13 @@ export const OAuthFlowProgress = ({
const [clientInfo, setClientInfo] = useState<OAuthClientInformation | null>(
null,
);
const authorizationServerMetadataDiscoveryUrl = useMemo(() => {
if (!authState.authServerUrl) {
return null;
}
return getAuthorizationServerMetadataDiscoveryUrl(authState.authServerUrl);
}, [authState.authServerUrl]);
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
@ -197,13 +205,7 @@ export const OAuthFlowProgress = ({
<p className="font-medium">Authorization Server Metadata:</p>
{authState.authServerUrl && (
<p className="text-xs text-muted-foreground">
From{" "}
{
new URL(
"/.well-known/oauth-authorization-server",
authState.authServerUrl,
).href
}
From {authorizationServerMetadataDiscoveryUrl}
</p>
)}
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">

View file

@ -22,17 +22,14 @@ const checkContentCompatibility = (
text?: string;
[key: string]: unknown;
}>,
): { isCompatible: boolean; message: string } => {
): { hasMatch: boolean; message: string } | null => {
// Look for at least one text content block that matches the structured content
const textBlocks = unstructuredContent.filter(
(block) => block.type === "text",
);
if (textBlocks.length === 0) {
return {
isCompatible: false,
message: "No text blocks to match structured content",
};
return null;
}
// Check if any text block contains JSON that matches the structured content
@ -49,7 +46,7 @@ const checkContentCompatibility = (
if (isEqual) {
return {
isCompatible: true,
hasMatch: true,
message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`,
};
}
@ -59,10 +56,7 @@ const checkContentCompatibility = (
}
}
return {
isCompatible: false,
message: "No text block matches structured content",
};
return null;
};
const ToolResults = ({
@ -195,16 +189,9 @@ const ToolResults = ({
<h5 className="font-semibold mb-2 text-sm">
Unstructured Content:
</h5>
{compatibilityResult && (
<div
className={`mb-2 p-2 rounded text-sm ${
compatibilityResult.isCompatible
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
}`}
>
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
{compatibilityResult.message}
{compatibilityResult?.hasMatch && (
<div className="mb-2 p-2 rounded text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
✓ {compatibilityResult.message}
</div>
)}
</>

View file

@ -24,6 +24,7 @@ import {
CompatibilityCallToolResult,
ListToolsResult,
Tool,
ToolAnnotations,
} from "@modelcontextprotocol/sdk/types.js";
import {
Loader2,
@ -52,9 +53,116 @@ 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 = (
tool: Tool,
): tool is Tool & { annotations: ToolAnnotations } =>
typeof (tool as { annotations?: unknown }).annotations !== "undefined" &&
(tool as { annotations?: unknown }).annotations !== null;
// Helper to render annotation badges
// Shows all 4 annotation values with their state (true/false/implied default)
const AnnotationBadges = ({
annotations,
}: {
annotations: ToolAnnotations | undefined;
}) => {
// Spec defaults: readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true
const getValueAndImplied = (
value: boolean | undefined,
defaultValue: boolean,
): { value: boolean; implied: boolean } => ({
value: value ?? defaultValue,
implied: value === undefined,
});
const readOnly = getValueAndImplied(annotations?.readOnlyHint, false);
const destructive = getValueAndImplied(annotations?.destructiveHint, true);
const idempotent = getValueAndImplied(annotations?.idempotentHint, false);
const openWorld = getValueAndImplied(annotations?.openWorldHint, true);
// Descriptions from MCP spec
const badges = [
{
label: "Read-only",
value: readOnly.value,
implied: readOnly.implied,
description: "Tool does not modify its environment",
},
{
label: "Destructive",
value: destructive.value,
implied: destructive.implied,
description:
"Tool may perform destructive updates (delete/overwrite data)",
},
{
label: "Idempotent",
value: idempotent.value,
implied: idempotent.implied,
description: "Calling repeatedly with same args has no additional effect",
},
{
label: "Open-world",
value: openWorld.value,
implied: openWorld.implied,
description:
"Tool may interact with external entities beyond its local environment",
},
];
return (
<div className="flex flex-wrap gap-1 mt-2">
{badges.map(({ label, value, implied, description }) => (
<span
key={label}
title={`${description}\n\nValue: ${value ? "Yes" : "No"} (${implied ? "implied default" : "explicitly set"})`}
className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
"bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600",
implied && "border-dashed opacity-60",
!implied && "border-solid",
)}
>
{value ? "✓" : "✗"} {label}
</span>
))}
</div>
);
};
const ToolsTab = ({
tools,
@ -69,6 +177,7 @@ const ToolsTab = ({
error,
resourceContent,
onReadResource,
serverSupportsTaskRequests,
}: {
tools: Tool[];
listTools: () => void;
@ -78,7 +187,7 @@ const ToolsTab = ({
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<void>;
) => Promise<CompatibilityCallToolResult>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
@ -87,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);
@ -131,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();
@ -155,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">
@ -170,10 +287,10 @@ 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.name}</span>
<span className="truncate">{tool.title || tool.name}</span>
<span className="text-sm text-gray-500 text-left line-clamp-2">
{tool.description}
</span>
@ -191,12 +308,14 @@ const ToolsTab = ({
<div className="flex items-center gap-2">
{selectedTool && (
<IconDisplay
icons={(selectedTool as WithIcons).icons}
icons={(selectedTool as ExtendedTool).icons}
size="md"
/>
)}
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
{selectedTool
? selectedTool.title || selectedTool.name
: "Select a tool"}
</h3>
</div>
</div>
@ -215,6 +334,13 @@ const ToolsTab = ({
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedTool.description}
</p>
<AnnotationBadges
annotations={
hasAnnotations(selectedTool)
? selectedTool.annotations
: undefined
}
/>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
// First resolve any $ref references
@ -659,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

View file

@ -0,0 +1,264 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import AppRenderer from "../AppRenderer";
import {
Tool,
CompatibilityCallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { RequestHandlerExtra } from "@mcp-ui/client";
import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps";
type BridgeEvent = {
type: "sendToolInput" | "sendToolResult";
toolName: string;
payload: unknown;
};
type MockMcpUiRendererProps = {
toolName: string;
toolInput?: Record<string, unknown>;
toolResult?: CompatibilityCallToolResult;
onMessage?: (
params: { role: "user"; content: { type: "text"; text: string }[] },
extra: RequestHandlerExtra,
) => Promise<McpUiMessageResult>;
};
const mockBridgeEvents: BridgeEvent[] = [];
// Mock the ext-apps module
jest.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({
getToolUiResourceUri: (tool: Tool) => {
const meta = (tool as Tool & { _meta?: { ui?: { resourceUri?: string } } })
._meta;
return meta?.ui?.resourceUri || null;
},
}));
// Mock toast hook
const mockToast = jest.fn();
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
// Mock @mcp-ui/client to simulate bridge delivery only after app initialization
jest.mock("@mcp-ui/client", () => {
const React = jest.requireActual("react") as typeof import("react");
return {
AppRenderer: ({
toolName,
toolInput,
toolResult,
onMessage,
}: MockMcpUiRendererProps) => {
const [isInitialized, setIsInitialized] = React.useState(false);
React.useEffect(() => {
setIsInitialized(false);
}, [toolName]);
React.useEffect(() => {
if (isInitialized && toolInput) {
mockBridgeEvents.push({
type: "sendToolInput",
toolName,
payload: toolInput,
});
}
}, [isInitialized, toolInput, toolName]);
React.useEffect(() => {
if (isInitialized && toolResult) {
mockBridgeEvents.push({
type: "sendToolResult",
toolName,
payload: toolResult,
});
}
}, [isInitialized, toolResult, toolName]);
return (
<div data-testid="mcp-ui-app-renderer">
<div data-testid="tool-name">{toolName}</div>
<div data-testid="tool-result">
{JSON.stringify(toolResult ?? null)}
</div>
<button
data-testid="initialize-app"
onClick={() => setIsInitialized(true)}
>
Initialize App
</button>
<button
data-testid="trigger-message"
onClick={() =>
onMessage?.(
{
role: "user",
content: [{ type: "text", text: "Test message" }],
},
{} as RequestHandlerExtra,
)
}
>
Trigger Message
</button>
</div>
);
},
};
});
describe("AppRenderer", () => {
const mockTool: Tool = {
name: "testApp",
description: "Test app with UI",
inputSchema: {
type: "object" as const,
properties: {},
},
_meta: {
ui: {
resourceUri: "ui://test-app",
},
},
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockMcpClient = {
getServerCapabilities: jest.fn().mockReturnValue({}),
setNotificationHandler: jest.fn(),
} as unknown as Client;
const defaultProps = {
sandboxPath: "/sandbox",
tool: mockTool,
mcpClient: mockMcpClient,
toolResult: null,
};
beforeEach(() => {
jest.clearAllMocks();
mockBridgeEvents.length = 0;
});
it("should display waiting state when mcpClient is null", () => {
render(<AppRenderer {...defaultProps} mcpClient={null} />);
expect(screen.getByText(/Waiting for MCP client/i)).toBeInTheDocument();
});
it("should render McpUiAppRenderer when client is ready", () => {
render(<AppRenderer {...defaultProps} />);
expect(screen.getByTestId("mcp-ui-app-renderer")).toBeInTheDocument();
expect(screen.getByTestId("tool-name")).toHaveTextContent("testApp");
});
it("should set minimum height on container", () => {
render(<AppRenderer {...defaultProps} />);
const container = screen.getByTestId("mcp-ui-app-renderer").parentElement;
expect(container).toHaveStyle({ minHeight: "400px" });
});
it("should show toast when onMessage is triggered", () => {
render(<AppRenderer {...defaultProps} />);
fireEvent.click(screen.getByTestId("trigger-message"));
expect(mockToast).toHaveBeenCalledWith({
description: "Test message",
});
});
it("should send provided tool input and tool result after app initialization", async () => {
const result: CompatibilityCallToolResult = {
content: [{ type: "text", text: "Budget initialized" }],
};
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={result}
/>,
);
expect(mockBridgeEvents).toHaveLength(0);
fireEvent.click(screen.getByTestId("initialize-app"));
await waitFor(() => {
const toolEvents = mockBridgeEvents.filter(
(event) => event.toolName === "testApp",
);
expect(toolEvents.map((event) => event.type)).toEqual([
"sendToolInput",
"sendToolResult",
]);
});
const resultEvent = mockBridgeEvents.find(
(event) =>
event.toolName === "testApp" && event.type === "sendToolResult",
);
expect(resultEvent?.payload).toEqual(result);
});
it("should not send tool result event when toolResult is null", async () => {
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={null}
/>,
);
fireEvent.click(screen.getByTestId("initialize-app"));
await waitFor(() => {
const inputEvents = mockBridgeEvents.filter(
(event) =>
event.toolName === "testApp" && event.type === "sendToolInput",
);
expect(inputEvents).toHaveLength(1);
});
const resultEvents = mockBridgeEvents.filter(
(event) =>
event.toolName === "testApp" && event.type === "sendToolResult",
);
expect(resultEvents).toHaveLength(0);
});
it("should normalize compatibility wrapper tool results before sending", async () => {
const wrappedResult = {
toolResult: {
content: [{ type: "text", text: "Wrapped result payload" }],
},
} as CompatibilityCallToolResult;
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={wrappedResult}
/>,
);
fireEvent.click(screen.getByTestId("initialize-app"));
await waitFor(() => {
const resultEvent = mockBridgeEvents.find(
(event) =>
event.toolName === "testApp" && event.type === "sendToolResult",
);
expect(resultEvent).toBeTruthy();
expect(resultEvent?.payload).toEqual(wrappedResult.toolResult);
});
});
});

View file

@ -0,0 +1,772 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import AppsTab from "../AppsTab";
import {
Tool,
CompatibilityCallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "../ui/tabs";
// Mock AppRenderer component
jest.mock("../AppRenderer", () => {
return function MockAppRenderer({
tool,
toolInput,
toolResult,
}: {
tool: Tool;
toolInput?: Record<string, unknown>;
toolResult?: unknown;
}) {
return (
<div data-testid="app-renderer">
<div>Tool: {tool.name}</div>
<div data-testid="tool-input">{JSON.stringify(toolInput)}</div>
<div data-testid="tool-result">
{JSON.stringify(toolResult ?? null)}
</div>
</div>
);
};
});
describe("AppsTab", () => {
const mockAppTool: Tool = {
name: "weatherApp",
description: "Weather app with UI",
inputSchema: {
type: "object" as const,
properties: {},
},
_meta: {
ui: {
resourceUri: "ui://weather-app",
},
},
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockRegularTool: Tool = {
name: "regularTool",
description: "Regular tool without UI",
inputSchema: {
type: "object" as const,
properties: {},
},
};
const defaultProps = {
tools: [],
listTools: jest.fn(),
callTool: jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
),
error: null,
mcpClient: null,
};
const renderAppsTab = (props = {}) => {
return render(
<Tabs defaultValue="apps">
<AppsTab {...defaultProps} {...props} />
</Tabs>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it("should display message when no apps are available", () => {
renderAppsTab();
expect(screen.getByText(/No MCP Apps available/i)).toBeInTheDocument();
expect(screen.getByText(/_meta\.ui\.resourceUri/)).toBeInTheDocument();
});
it("should filter and display only tools with UI metadata", () => {
renderAppsTab({
tools: [mockAppTool, mockRegularTool],
});
// Should show the app tool
expect(screen.getByText("weatherApp")).toBeInTheDocument();
expect(screen.getByText("Weather app with UI")).toBeInTheDocument();
// Should not show the regular tool
expect(screen.queryByText("regularTool")).not.toBeInTheDocument();
});
it("should display multiple app tools in a grid", () => {
const mockAppTool2: Tool = {
name: "calendarApp",
description: "Calendar app with UI",
inputSchema: {
type: "object" as const,
properties: {},
},
_meta: {
ui: {
resourceUri: "ui://calendar-app",
},
},
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [mockAppTool, mockAppTool2],
});
expect(screen.getByText("weatherApp")).toBeInTheDocument();
expect(screen.getByText("calendarApp")).toBeInTheDocument();
});
it("should call listTools when refresh button is clicked", () => {
const mockListTools = jest.fn();
renderAppsTab({
tools: [mockAppTool],
listTools: mockListTools,
});
const refreshButton = screen.getByRole("button", { name: /refresh/i });
fireEvent.click(refreshButton);
expect(mockListTools).toHaveBeenCalledTimes(1);
});
it("should display error message when error prop is provided", () => {
const errorMessage = "Failed to fetch tools";
renderAppsTab({
error: errorMessage,
});
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
type: "object",
properties: {
field1: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
const appCard = screen.getByText("fieldsApp").closest("div");
expect(appCard).toBeTruthy();
fireEvent.click(appCard!);
// Should see the input form now, not the renderer yet
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
expect(screen.getByText("App Input")).toBeInTheDocument();
// Click Open App button
const openAppButton = screen.getByRole("button", { name: /open app/i });
fireEvent.click(openAppButton);
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("fieldsApp", {
field1: undefined,
});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
});
it("should close app renderer when close button is clicked", async () => {
renderAppsTab({
tools: [mockAppTool],
});
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Close the app (Deselect tool)
const closeButton = screen.getByRole("button", { name: /close app/i });
fireEvent.click(closeButton);
// AppRenderer should be removed
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
});
it("should handle tool without description", () => {
const toolWithoutDescription: Tool = {
name: "noDescriptionApp",
inputSchema: {
type: "object" as const,
properties: {},
},
_meta: {
ui: {
resourceUri: "ui://no-description-app",
},
},
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithoutDescription],
});
expect(screen.getByText("noDescriptionApp")).toBeInTheDocument();
});
it("should reset selected tool when tools list changes and selected tool is removed", async () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Update tools list to remove the selected tool
rerender(
<Tabs defaultValue="apps">
<AppsTab {...defaultProps} tools={[]} />
</Tabs>,
);
// AppRenderer should be removed
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
});
it("should maintain selected tool when tools list updates but includes the same tool", async () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Update tools list with the same tool
rerender(
<Tabs defaultValue="apps">
<AppsTab {...defaultProps} tools={[mockAppTool]} />
</Tabs>,
);
// AppRenderer should still be rendered
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
it("should maximize and minimize the app window", async () => {
renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /maximize/i }),
).toBeInTheDocument();
});
// Initially, ListPane should be visible
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
// Click Maximize
const maximizeButton = screen.getByRole("button", { name: /maximize/i });
fireEvent.click(maximizeButton);
// ListPane should be hidden
expect(screen.queryByText("MCP Apps")).not.toBeInTheDocument();
// Click Minimize
const minimizeButton = screen.getByRole("button", { name: /minimize/i });
fireEvent.click(minimizeButton);
// ListPane should be visible again
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
});
it("should reset maximized state when app is closed", async () => {
renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /maximize/i }),
).toBeInTheDocument();
});
// Maximize
fireEvent.click(screen.getByRole("button", { name: /maximize/i }));
expect(screen.queryByText("MCP Apps")).not.toBeInTheDocument();
// Close app (deselect tool)
fireEvent.click(screen.getByRole("button", { name: /close app/i }));
// ListPane should be visible again
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
});
it("should display app cards in the list", () => {
renderAppsTab({
tools: [mockAppTool],
});
const appItem = screen.getByText("weatherApp").closest("div");
expect(appItem).toBeTruthy();
});
it("should handle empty resourceContentMap", async () => {
renderAppsTab({
tools: [mockAppTool],
});
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
});
it("should handle various input types and pass them to AppRenderer", async () => {
const toolWithComplexSchema: Tool = {
name: "complexApp",
inputSchema: {
type: "object",
properties: {
text: { type: "string" },
toggle: { type: "boolean" },
number: { type: "number" },
choice: { type: "string", enum: ["a", "b"] },
},
},
_meta: { ui: { resourceUri: "ui://complex" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithComplexSchema],
});
fireEvent.click(screen.getByText("complexApp"));
// Fill the form
fireEvent.change(screen.getByLabelText("text"), {
target: { value: "hello" },
});
// Checkboxes are often rendered with a label next to them, let's look for the text
const toggleLabel = screen.getByText(/Toggle this option/i);
fireEvent.click(toggleLabel);
fireEvent.change(screen.getByLabelText("number"), {
target: { value: "42" },
});
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput.text).toBe("hello");
expect(toolInput.toggle).toBe(true);
expect(toolInput.number).toBe(42);
});
it("should handle nullable fields", async () => {
const toolWithNullable: Tool = {
name: "nullableApp",
inputSchema: {
type: "object",
properties: {
nullableField: { type: ["string", "null"] },
},
},
_meta: { ui: { resourceUri: "ui://nullable" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithNullable],
});
fireEvent.click(screen.getByText("nullableApp"));
// Check the 'null' checkbox
const nullCheckbox = screen.getByLabelText("null");
fireEvent.click(nullCheckbox);
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput.nullableField).toBe(null);
});
it("should allow going back to input form from app renderer", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
type: "object",
properties: {
field1: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithFields],
});
fireEvent.click(screen.getByText("fieldsApp"));
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const backButton = screen.queryByRole("button", { name: /back to input/i });
expect(backButton).toBeInTheDocument();
});
it("should skip input form if tool has no input fields", async () => {
const toolNoFields: Tool = {
name: "noFieldsApp",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://no-fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
);
renderAppsTab({
tools: [toolNoFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("noFieldsApp"));
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("noFieldsApp", {});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
expect(screen.getByText("Tool: noFieldsApp")).toBeInTheDocument();
// Should NOT see the back button
expect(
screen.queryByRole("button", { name: /back to input/i }),
).not.toBeInTheDocument();
});
it("should allow going back to input form from app renderer if fields exist", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
type: "object",
properties: {
field1: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithFields],
});
fireEvent.click(screen.getByText("fieldsApp"));
// Should see input form first
expect(screen.getByText("App Input")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /back to input/i });
fireEvent.click(backButton);
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
expect(screen.getByText("App Input")).toBeInTheDocument();
});
it("should auto-render app from prefilled tools tab call without manual click", async () => {
const toolWithFields: Tool = {
name: "prefilledApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://prefilled" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const onPrefilledToolCallConsumed = jest.fn();
const prefilledResult: CompatibilityCallToolResult = {
content: [{ type: "text", text: "weather result" }],
};
renderAppsTab({
tools: [toolWithFields],
prefilledToolCall: {
id: 42,
toolName: "prefilledApp",
params: { city: "Lisbon" },
result: prefilledResult,
},
onPrefilledToolCallConsumed,
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
expect(screen.getByText("Tool: prefilledApp")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput).toEqual({ city: "Lisbon" });
expect(screen.getByTestId("tool-result")).toHaveTextContent(
"weather result",
);
expect(onPrefilledToolCallConsumed).toHaveBeenCalledWith(42);
});
it("should not auto-render app when no prefilled tool call is provided", () => {
const toolWithFields: Tool = {
name: "manualApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://manual" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithFields],
});
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
expect(
screen.getByText("Select an app from the list to get started"),
).toBeInTheDocument();
});
it("should preserve submitted params while opening app", async () => {
const toolWithFields: Tool = {
name: "paramsApp",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://params" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
let resolveCall: ((result: CompatibilityCallToolResult) => void) | null =
null;
const mockCallTool = jest.fn(
() =>
new Promise<CompatibilityCallToolResult>((resolve) => {
resolveCall = resolve;
}),
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("paramsApp"));
fireEvent.change(screen.getByLabelText("query"), {
target: { value: "first value" },
});
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
fireEvent.change(screen.getByLabelText("query"), {
target: { value: "second value" },
});
expect(resolveCall).toBeTruthy();
await act(async () => {
resolveCall?.({
content: [{ type: "text", text: "done" }],
});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput.query).toBe("first value");
});
it("should keep input view when opening fails and recover button state", async () => {
const toolWithFields: Tool = {
name: "failingApp",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://failing" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
await Promise.reject<CompatibilityCallToolResult>(
new Error("tool failed"),
),
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("failingApp"));
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("failingApp", {
query: undefined,
});
});
await waitFor(() => {
const openAppButton = screen.getByRole("button", { name: /open app/i });
expect(openAppButton).toBeEnabled();
});
expect(screen.getByText("App Input")).toBeInTheDocument();
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
});
it("should ignore stale results from older app launches", async () => {
const appA: Tool = {
name: "appA",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://app-a" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const appB: Tool = {
name: "appB",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://app-b" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const pendingCalls: {
name: string;
resolve: (result: CompatibilityCallToolResult) => void;
}[] = [];
const mockCallTool = jest.fn(
(name: string) =>
new Promise<CompatibilityCallToolResult>((resolve) => {
pendingCalls.push({ name, resolve });
}),
);
renderAppsTab({
tools: [appA, appB],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("appA"));
fireEvent.click(screen.getByText("appB"));
const appBCall = pendingCalls.find((call) => call.name === "appB");
const appACall = pendingCalls.find((call) => call.name === "appA");
expect(appBCall).toBeTruthy();
expect(appACall).toBeTruthy();
await act(async () => {
appBCall?.resolve({
content: [{ type: "text", text: "result from appB" }],
});
});
await waitFor(() => {
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
});
await act(async () => {
appACall?.resolve({
content: [{ type: "text", text: "result from appA" }],
});
});
await waitFor(() => {
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
expect(screen.getByTestId("tool-result")).not.toHaveTextContent("appA");
});
});
});

View file

@ -743,7 +743,7 @@ describe("AuthDebugger", () => {
const updateAuthState = jest.fn();
const mockResourceMetadata = {
resource: "https://example.com/mcp",
authorization_servers: ["https://custom-auth.example.com"],
authorization_servers: ["https://custom-auth.example.com/mcp/tenant"],
bearer_methods_supported: ["header", "body"],
resource_documentation: "https://example.com/mcp/docs",
resource_policy_uri: "https://example.com/mcp/policy",
@ -797,11 +797,17 @@ describe("AuthDebugger", () => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata: mockResourceMetadata,
authServerUrl: new URL("https://custom-auth.example.com"),
authServerUrl: new URL(
"https://custom-auth.example.com/mcp/tenant",
),
oauthStep: "client_registration",
}),
);
});
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://custom-auth.example.com/mcp/tenant"),
);
});
it("should handle protected resource metadata fetch failure gracefully", async () => {

View file

@ -1,7 +1,7 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import { useRef } from "react";
import { useRef, useState } from "react";
import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@ -402,6 +402,29 @@ describe("DynamicJsonForm Number Fields", () => {
expect(onChange).toHaveBeenCalledWith(98.6);
});
it("should preserve decimal zero while typing", () => {
const schema: JsonSchemaType = {
type: "number",
description: "Coordinate",
};
const WrappedForm = () => {
const [value, setValue] = useState<number>(0);
return (
<DynamicJsonForm schema={schema} value={value} onChange={setValue} />
);
};
render(<WrappedForm />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "-74.0" } });
expect(input.value).toBe("-74.0");
fireEvent.change(input, { target: { value: "-74.01" } });
expect(input.value).toBe("-74.01");
});
});
});

View file

@ -13,7 +13,6 @@ describe("ListPane", () => {
const defaultProps = {
items: mockItems,
listItems: jest.fn(),
clearItems: jest.fn(),
setSelectedItem: jest.fn(),
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
title: "List tools",
@ -36,6 +35,13 @@ describe("ListPane", () => {
expect(
screen.getByRole("button", { name: "Load Tools" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Clear" }),
).not.toBeInTheDocument();
});
it("should render Clear button when clearItems prop is provided", () => {
renderListPane({ clearItems: jest.fn() });
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});

View file

@ -1,6 +1,6 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Tabs } from "@/components/ui/tabs";
import { Tabs } from "../ui/tabs";
import ResourcesTab from "../ResourcesTab";
import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js";

View file

@ -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";
@ -73,6 +73,7 @@ describe("ToolsTab", () => {
error: null,
resourceContent: {},
onReadResource: jest.fn(),
serverSupportsTaskRequests: true,
};
const renderToolsTab = (props = {}) => {
@ -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

View file

@ -2,6 +2,7 @@ import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
generateOAuthState,
getAuthorizationServerMetadataDiscoveryUrl,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
@ -84,3 +85,29 @@ describe("generateOAuthErrorDescription", () => {
});
});
});
describe("getAuthorizationServerMetadataDiscoveryUrl", () => {
it("uses root discovery URL for root authorization server URL", () => {
expect(
getAuthorizationServerMetadataDiscoveryUrl("https://example.com"),
).toBe("https://example.com/.well-known/oauth-authorization-server");
});
it("inserts tenant path for non-root authorization server URL", () => {
expect(
getAuthorizationServerMetadataDiscoveryUrl("https://example.com/tenant1"),
).toBe(
"https://example.com/.well-known/oauth-authorization-server/tenant1",
);
});
it("strips trailing slash before appending tenant path", () => {
expect(
getAuthorizationServerMetadataDiscoveryUrl(
"https://example.com/tenant1/",
),
).toBe(
"https://example.com/.well-known/oauth-authorization-server/tenant1",
);
});
});

View file

@ -87,3 +87,31 @@ export const generateOAuthErrorDescription = (
.filter(Boolean)
.join("\n");
};
/**
* Returns the primary OAuth authorization server metadata discovery URL
* for a given authorization server URL, including tenant path handling.
*/
export const getAuthorizationServerMetadataDiscoveryUrl = (
authorizationServerUrl: string | URL,
): string => {
const url =
typeof authorizationServerUrl === "string"
? new URL(authorizationServerUrl)
: authorizationServerUrl;
const hasPath = url.pathname !== "/";
if (!hasPath) {
return new URL("/.well-known/oauth-authorization-server", url.origin).href;
}
// Strip trailing slash to avoid double slashes in tenant-aware discovery URLs.
const pathname = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
: url.pathname;
return new URL(
`/.well-known/oauth-authorization-server${pathname}`,
url.origin,
).href;
};

View file

@ -159,20 +159,46 @@ export function isPropertyRequired(
* Resolves $ref references in JSON schema
* @param schema The schema that may contain $ref
* @param rootSchema The root schema to resolve references against
* @param visitedRefs Optional set of visited $ref paths to detect circular references
* @returns The resolved schema without $ref
*/
export function resolveRef(
schema: JsonSchemaType,
rootSchema: JsonSchemaType,
visitedRefs: Set<string> = new Set(),
): JsonSchemaType {
if (!schema) return schema;
if (!("$ref" in schema) || !schema.$ref) {
// Recursively resolve $ref in anyOf (and other nested structures)
if (schema.anyOf && Array.isArray(schema.anyOf)) {
const resolvedAnyOf = schema.anyOf.map((item) => {
if (typeof item === "object" && item !== null) {
return resolveRef(item, rootSchema, visitedRefs);
}
return item;
});
return {
...schema,
anyOf: resolvedAnyOf,
};
}
return schema;
}
const ref = schema.$ref;
// Handle simple #/properties/name references
// Handle all #/ formats (#/properties/, #/$defs/, etc.)
if (ref.startsWith("#/")) {
// Check for circular reference
if (visitedRefs.has(ref)) {
console.warn(`Circular reference detected: ${ref}`);
return schema;
}
// Add current ref to visited set
visitedRefs.add(ref);
const path = ref.substring(2).split("/");
let current: unknown = rootSchema;
@ -186,12 +212,16 @@ export function resolveRef(
current = (current as Record<string, unknown>)[segment];
} else {
// If reference cannot be resolved, return the original schema
visitedRefs.delete(ref); // Clean up on failure
console.warn(`Could not resolve $ref: ${ref}`);
return schema;
}
}
return current as JsonSchemaType;
const resolved = current as JsonSchemaType;
// Recursively resolve nested structures (anyOf, oneOf, items, properties)
return resolveRef(resolved, rootSchema, visitedRefs);
}
// For other types of references, return the original schema
@ -205,54 +235,28 @@ export function resolveRef(
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
// Handle anyOf with exactly string and null (FastMCP pattern)
// Handle anyOf with exactly 2 items (type and null) - unified handling
// Preserves enum and other properties automatically
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "string", anyOf: undefined, nullable: true };
}
const nonNullItem = schema.anyOf.find((t) => {
const item = t as JsonSchemaType;
return item?.type !== "null";
}) as JsonSchemaType;
// Handle anyOf with exactly boolean and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly number and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "number", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly integer and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly array and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "array", anyOf: undefined, nullable: true };
// Only process if non-null item has type or enum
if (nonNullItem?.type || nonNullItem?.enum) {
return {
...schema,
...nonNullItem,
type: nonNullItem?.type || (nonNullItem?.enum ? "string" : undefined),
nullable: true,
anyOf: undefined,
};
}
}
// Handle array type with exactly string and null