Merge branch 'main' into feature/tool-annotations-display

This commit is contained in:
Ola Hungerford 2026-02-07 16:44:55 -07:00 • committed by GitHub
commit d1824ca3f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2262 additions and 233 deletions

6
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View file

@ -33,7 +33,7 @@ jobs:
github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review_comment' ||
github.event_name == 'pull_request_review' github.event_name == 'pull_request_review'
id: pr id: pr
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
script: | script: |
let prNumber; let prNumber;

View file

@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version-file: package.json node-version-file: package.json
cache: npm cache: npm

View file

@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
id: setup_node id: setup_node
with: with:
node-version-file: package.json node-version-file: package.json
@ -29,7 +29,7 @@ jobs:
# Cache Playwright browsers # Cache Playwright browsers
- name: Cache Playwright browsers - name: Cache Playwright browsers
id: cache-playwright id: cache-playwright
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright # The default Playwright cache path path: ~/.cache/ms-playwright # The default Playwright cache path
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json
@ -51,7 +51,7 @@ jobs:
run: npm run test:e2e run: npm run test:e2e
- name: Upload Playwright Report and Screenshots - name: Upload Playwright Report and Screenshots
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
if: steps.playwright-tests.conclusion != 'skipped' if: steps.playwright-tests.conclusion != 'skipped'
with: with:
name: playwright-report name: playwright-report

View file

@ -17,7 +17,7 @@ jobs:
- name: Check formatting - name: Check formatting
run: npx prettier --check . run: npx prettier --check .
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version-file: package.json node-version-file: package.json
cache: npm cache: npm
@ -51,7 +51,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version-file: package.json node-version-file: package.json
cache: npm cache: npm
@ -109,7 +109,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation - name: Generate artifact attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v3
with: with:
subject-name: ghcr.io/${{ github.repository }} subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

View file

@ -1,18 +1,30 @@
import { expect } from "vitest"; import { expect } from "vitest";
import type { CliResult } from "./cli-runner.js"; import type { CliResult } from "./cli-runner.js";
function formatCliOutput(result: CliResult): string {
const out = result.stdout?.trim() || "(empty)";
const err = result.stderr?.trim() || "(empty)";
return `stdout: ${out}\nstderr: ${err}`;
}
/** /**
* Assert that CLI command succeeded (exit code 0) * Assert that CLI command succeeded (exit code 0)
*/ */
export function expectCliSuccess(result: CliResult) { export function expectCliSuccess(result: CliResult) {
expect(result.exitCode).toBe(0); expect(
result.exitCode,
`CLI exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
).toBe(0);
} }
/** /**
* Assert that CLI command failed (non-zero exit code) * Assert that CLI command failed (non-zero exit code)
*/ */
export function expectCliFailure(result: CliResult) { export function expectCliFailure(result: CliResult) {
expect(result.exitCode).not.toBe(0); expect(
result.exitCode,
`CLI unexpectedly exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
).not.toBe(0);
} }
/** /**

View file

@ -1,7 +1,7 @@
import fs from "fs"; import * as fs from "fs";
import path from "path"; import * as path from "path";
import os from "os"; import * as os from "os";
import crypto from "crypto"; import * as crypto from "crypto";
import { getTestMcpServerCommand } from "./test-server-stdio.js"; import { getTestMcpServerCommand } from "./test-server-stdio.js";
/** /**

View file

@ -6,6 +6,7 @@ import type { Request, Response } from "express";
import express from "express"; import express from "express";
import { createServer as createHttpServer, Server as HttpServer } from "http"; import { createServer as createHttpServer, Server as HttpServer } from "http";
import { createServer as createNetServer } from "net"; import { createServer as createNetServer } from "net";
import { randomUUID } from "crypto";
import * as z from "zod/v4"; import * as z from "zod/v4";
import type { ServerConfig } from "./test-fixtures.js"; import type { ServerConfig } from "./test-fixtures.js";
@ -187,22 +188,24 @@ export class TestServerHttp {
} }
/** /**
* Start the server with the specified transport * Start the server with the specified transport.
* When requestedPort is omitted, uses port 0 so the OS assigns a unique port (avoids EADDRINUSE when tests run in parallel).
*/ */
async start( async start(
transport: "http" | "sse", transport: "http" | "sse",
requestedPort?: number, requestedPort?: number,
): Promise<number> { ): Promise<number> {
const port = requestedPort const port =
? await findAvailablePort(requestedPort) requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0;
: await findAvailablePort(transport === "http" ? 3001 : 3000);
this.url = `http://localhost:${port}`;
if (transport === "http") { if (transport === "http") {
return this.startHttp(port); const actualPort = await this.startHttp(port);
this.url = `http://localhost:${actualPort}`;
return actualPort;
} else { } else {
return this.startSse(port); const actualPort = await this.startSse(port);
this.url = `http://localhost:${actualPort}`;
return actualPort;
} }
} }
@ -213,8 +216,10 @@ export class TestServerHttp {
// Create HTTP server // Create HTTP server
this.httpServer = createHttpServer(app); this.httpServer = createHttpServer(app);
// Create StreamableHTTP transport // Create StreamableHTTP transport (stateful so it can handle multiple requests per session)
this.transport = new StreamableHTTPServerTransport({}); this.transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
// Set up Express route to handle MCP requests // Set up Express route to handle MCP requests
app.post("/mcp", async (req: Request, res: Response) => { app.post("/mcp", async (req: Request, res: Response) => {
@ -292,10 +297,12 @@ export class TestServerHttp {
// Connect transport to server // Connect transport to server
await this.mcpServer.connect(this.transport); await this.mcpServer.connect(this.transport);
// Start listening // Start listening (port 0 = OS assigns a unique port)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.httpServer!.listen(port, () => { this.httpServer!.listen(port, () => {
resolve(port); const assignedPort = (this.httpServer!.address() as { port: number })
?.port;
resolve(assignedPort ?? port);
}); });
this.httpServer!.on("error", reject); this.httpServer!.on("error", reject);
}); });
@ -371,10 +378,12 @@ export class TestServerHttp {
// Note: SSE transport is created per request, so we don't store a single instance // Note: SSE transport is created per request, so we don't store a single instance
this.transport = undefined; this.transport = undefined;
// Start listening // Start listening (port 0 = OS assigns a unique port)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.httpServer!.listen(port, () => { this.httpServer!.listen(port, () => {
resolve(port); const assignedPort = (this.httpServer!.address() as { port: number })
?.port;
resolve(assignedPort ?? port);
}); });
this.httpServer!.on("error", reject); this.httpServer!.on("error", reject);
}); });

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-cli", "name": "@modelcontextprotocol/inspector-cli",
"version": "0.19.0", "version": "0.20.0",
"description": "CLI for the Model Context Protocol inspector", "description": "CLI for the Model Context Protocol inspector",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"author": "Model Context Protocol a Series of LF Projects, LLC.", "author": "Model Context Protocol a Series of LF Projects, LLC.",
@ -25,7 +25,7 @@
"test:cli-metadata": "vitest run metadata.test.ts" "test:cli-metadata": "vitest run metadata.test.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"vitest": "^4.0.17" "vitest": "^4.0.17"
}, },

View file

@ -56,9 +56,11 @@ async function startDevServer(serverOptions) {
echoOutput: true, echoOutput: true,
}; };
// For Windows, we need to use stdin: 'ignore' to simulate < NUL // For Windows, we need to ignore stdin to simulate < NUL
// spawn-rx's 'stdin' option expects an Observable, not 'ignore'
// Use Node's stdio option instead
if (isWindows) { if (isWindows) {
spawnOptions.stdin = "ignore"; spawnOptions.stdio = ["ignore", "pipe", "pipe"];
} }
const server = spawn(serverCommand, serverArgs, spawnOptions); const server = spawn(serverCommand, serverArgs, spawnOptions);
@ -140,13 +142,21 @@ async function startDevClient(clientOptions) {
const clientCommand = "npx"; const clientCommand = "npx";
const host = process.env.HOST || "localhost"; const host = process.env.HOST || "localhost";
const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host];
const isWindows = process.platform === "win32";
const client = spawn(clientCommand, clientArgs, { const spawnOptions = {
cwd: resolve(__dirname, ".."), cwd: resolve(__dirname, ".."),
env: { ...process.env, CLIENT_PORT }, env: { ...process.env, CLIENT_PORT },
signal: abort.signal, signal: abort.signal,
echoOutput: true, echoOutput: true,
}); };
// For Windows, we need to ignore stdin to prevent hanging
if (isWindows) {
spawnOptions.stdio = ["ignore", "pipe", "pipe"];
}
const client = spawn(clientCommand, clientArgs, spawnOptions);
const url = getClientUrl( const url = getClientUrl(
CLIENT_PORT, CLIENT_PORT,

View file

@ -13,8 +13,15 @@ module.exports = {
tsconfig: "tsconfig.jest.json", tsconfig: "tsconfig.jest.json",
}, },
], ],
"^.+\\.m?js$": [
"ts-jest",
{
tsconfig: "tsconfig.jest.json",
},
],
}, },
extensionsToTreatAsEsm: [".ts", ".tsx"], extensionsToTreatAsEsm: [".ts", ".tsx"],
transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested // Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [ testPathIgnorePatterns: [

View file

@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.19.0", "version": "0.20.0",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"author": "Model Context Protocol a Series of LF Projects, LLC.", "author": "Model Context Protocol a Series of LF Projects, LLC.",
@ -25,6 +25,8 @@
"cleanup:e2e": "node e2e/global-teardown.js" "cleanup:e2e": "node e2e/global-teardown.js"
}, },
"dependencies": { "dependencies": {
"@mcp-ui/client": "^6.0.0",
"@modelcontextprotocol/ext-apps": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",

View file

@ -51,6 +51,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
AppWindow,
Bell, Bell,
Files, Files,
FolderTree, FolderTree,
@ -75,6 +76,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab"; import ToolsTab from "./components/ToolsTab";
import TasksTab from "./components/TasksTab"; import TasksTab from "./components/TasksTab";
import AppsTab from "./components/AppsTab";
import { InspectorConfig } from "./lib/configurationTypes"; import { InspectorConfig } from "./lib/configurationTypes";
import { import {
getMCPProxyAddress, getMCPProxyAddress,
@ -126,6 +128,9 @@ const App = () => {
const [resourceContentMap, setResourceContentMap] = useState< const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string> Record<string, string>
>({}); >({});
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
new Set(),
);
const [prompts, setPrompts] = useState<Prompt[]>([]); const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>(""); const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]); const [tools, setTools] = useState<Tool[]>([]);
@ -308,11 +313,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping", "ping",
"sampling", "sampling",
"elicitations", "elicitations",
"roots", "roots",
"auth", "auth",
"metadata",
]; ];
if (!validTabs.includes(originatingTab)) return; if (!validTabs.includes(originatingTab)) return;
@ -440,11 +447,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping", "ping",
"sampling", "sampling",
"elicitations", "elicitations",
"roots", "roots",
"auth", "auth",
"metadata",
]; ];
const isValidTab = validTabs.includes(hash); const isValidTab = validTabs.includes(hash);
@ -473,6 +482,13 @@ const App = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab]); }, [mcpClient, activeTab]);
useEffect(() => {
if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) {
void listTools();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab, serverCapabilities?.tools]);
useEffect(() => { useEffect(() => {
localStorage.setItem("lastCommand", command); localStorage.setItem("lastCommand", command);
}, [command]); }, [command]);
@ -757,11 +773,13 @@ const App = () => {
...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []),
"apps",
"ping", "ping",
"sampling", "sampling",
"elicitations", "elicitations",
"roots", "roots",
"auth", "auth",
"metadata",
]; ];
if (validTabs.includes(originatingTab)) { if (validTabs.includes(originatingTab)) {
@ -851,22 +869,48 @@ const App = () => {
}; };
const readResource = async (uri: string) => { 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; lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest( try {
{ const response = await sendMCPRequest(
method: "resources/read" as const, {
params: { uri }, method: "resources/read" as const,
}, params: { uri },
ReadResourceResultSchema, },
"resources", ReadResourceResultSchema,
); "resources",
const content = JSON.stringify(response, null, 2); );
setResourceContent(content); console.log("[App] Resource read response:", {
setResourceContentMap((prev) => ({ uri,
...prev, responseLength: JSON.stringify(response).length,
[uri]: content, 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) => { const subscribeToResource = async (uri: string) => {
@ -1308,6 +1352,10 @@ const App = () => {
<ListTodo className="w-4 h-4 mr-2" /> <ListTodo className="w-4 h-4 mr-2" />
Tasks Tasks
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="apps">
<AppWindow className="w-4 h-4 mr-2" />
Apps
</TabsTrigger>
<TabsTrigger value="ping"> <TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" /> <Bell className="w-4 h-4 mr-2" />
Ping Ping
@ -1497,6 +1545,19 @@ const App = () => {
error={errors.tasks} error={errors.tasks}
nextCursor={nextTaskCursor} nextCursor={nextTaskCursor}
/> />
<AppsTab
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
error={errors.tools}
mcpClient={mcpClient}
onNotification={(notification) => {
setNotifications((prev) => [...prev, notification]);
}}
/>
<ConsoleTab /> <ConsoleTab />
<PingTab <PingTab
onPingClick={() => { onPingClick={() => {

View file

@ -0,0 +1,128 @@
import { useMemo, useState } from "react";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
Tool,
ContentBlock,
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>;
onNotification?: (notification: ServerNotification) => void;
}
const AppRenderer = ({
sandboxPath,
tool,
mcpClient,
toolInput,
onNotification,
}: AppRendererProps) => {
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
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}
sandbox={{
url: new URL(sandboxPath, window.location.origin),
}}
onError={(err) => setError(err.message)}
/>
</div>
</div>
);
};
export default AppRenderer;

View file

@ -0,0 +1,530 @@
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,
ChevronRight,
Maximize2,
Minimize2,
} from "lucide-react";
import { Tool, ServerNotification } 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;
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 AppsTab = ({
sandboxPath,
tools,
listTools,
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 [isMaximized, setIsMaximized] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
// 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);
}
}, [tools, selectedTool]);
useEffect(() => {
if (selectedTool) {
const initialParams = Object.entries(
selectedTool.inputSchema.properties ?? [],
).map(([key, value]) => {
// First resolve any $ref references
const resolvedValue = resolveRef(
value as JsonSchemaType,
selectedTool.inputSchema as JsonSchemaType,
);
return [
key,
generateDefaultValue(
resolvedValue,
key,
selectedTool.inputSchema as JsonSchemaType,
),
];
});
setParams(Object.fromEntries(initialParams));
setHasValidationErrors(false);
formRefs.current = {};
} else {
setParams({});
setIsAppOpen(false);
}
}, [selectedTool]);
const handleRefresh = useCallback(() => {
listTools();
}, [listTools]);
const handleCloseApp = useCallback(() => {
setIsAppOpen(false);
}, []);
const handleOpenApp = useCallback(() => {
if (!checkValidationErrors()) {
setIsAppOpen(true);
}
}, [checkValidationErrors]);
const handleSelectTool = useCallback((tool: Tool) => {
setSelectedTool(tool);
const hasFields =
tool.inputSchema.properties &&
Object.keys(tool.inputSchema.properties).length > 0;
setIsAppOpen(!hasFields);
}, []);
const handleDeselectTool = useCallback(() => {
setSelectedTool(null);
setIsAppOpen(false);
setIsMaximized(false);
}, []);
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={handleOpenApp}
className="w-full"
disabled={hasValidationErrors}
>
<Play className="w-4 h-4 mr-2" />
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={params}
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

@ -28,6 +28,7 @@ interface DynamicJsonFormProps {
export interface DynamicJsonFormRef { export interface DynamicJsonFormRef {
validateJson: () => { isValid: boolean; error: string | null }; validateJson: () => { isValid: boolean; error: string | null };
hasJsonError: () => boolean;
} }
const isTypeSupported = ( const isTypeSupported = (
@ -129,6 +130,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
// on every keystroke which would be inefficient and error-prone // on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const hasJsonError = () => {
return !!jsonError;
};
// Debounce JSON parsing and parent updates to handle typing gracefully // Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback( const debouncedUpdateParent = useCallback(
(jsonString: string) => { (jsonString: string) => {
@ -253,6 +258,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
validateJson, validateJson,
hasJsonError,
})); }));
const renderFormFields = ( const renderFormFields = (

View file

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

View file

@ -181,9 +181,11 @@ const ToolsTab = ({
const { copied, setCopied } = useCopy(); const { copied, setCopied } = useCopy();
// Function to check if any form has validation errors // Function to check if any form has validation errors
const checkValidationErrors = () => { const checkValidationErrors = (validateChildren: boolean = false) => {
const errors = Object.values(formRefs.current).some( const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid, (ref) =>
ref &&
(validateChildren ? !ref.validateJson().isValid : ref.hasJsonError()),
); );
setHasValidationErrors(errors); setHasValidationErrors(errors);
return errors; return errors;
@ -250,7 +252,7 @@ const ToolsTab = ({
<IconDisplay icons={(tool as WithIcons).icons} size="sm" /> <IconDisplay icons={(tool as WithIcons).icons} size="sm" />
</div> </div>
<div className="flex flex-col flex-1 min-w-0"> <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"> <span className="text-sm text-gray-500 text-left line-clamp-2">
{tool.description} {tool.description}
</span> </span>
@ -273,7 +275,9 @@ const ToolsTab = ({
/> />
)} )}
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"} {selectedTool
? selectedTool.title || selectedTool.name
: "Select a tool"}
</h3> </h3>
</div> </div>
</div> </div>
@ -761,7 +765,7 @@ const ToolsTab = ({
<Button <Button
onClick={async () => { onClick={async () => {
// Validate JSON inputs before calling tool // Validate JSON inputs before calling tool
if (checkValidationErrors()) return; if (checkValidationErrors(true)) return;
try { try {
setIsToolRunning(true); setIsToolRunning(true);

View file

@ -0,0 +1,115 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import AppRenderer from "../AppRenderer";
import { Tool } 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";
// 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
jest.mock("@mcp-ui/client", () => ({
AppRenderer: ({
toolName,
onMessage,
}: {
toolName: string;
onMessage?: (
params: { role: "user"; content: { type: "text"; text: string }[] },
extra: RequestHandlerExtra,
) => Promise<McpUiMessageResult>;
}) => (
<div data-testid="mcp-ui-app-renderer">
<div data-testid="tool-name">{toolName}</div>
<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 = {
request: jest.fn(),
getServerCapabilities: jest.fn().mockReturnValue({}),
setNotificationHandler: jest.fn(),
} as unknown as Client;
const defaultProps = {
sandboxPath: "/sandbox",
tool: mockTool,
mcpClient: mockMcpClient,
};
beforeEach(() => {
jest.clearAllMocks();
});
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",
});
});
});

View file

@ -0,0 +1,481 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import AppsTab from "../AppsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "../ui/tabs";
// Mock AppRenderer component
jest.mock("../AppRenderer", () => {
return function MockAppRenderer({
tool,
toolInput,
}: {
tool: Tool;
toolInput?: Record<string, unknown>;
}) {
return (
<div data-testid="app-renderer">
<div>Tool: {tool.name}</div>
<div data-testid="tool-input">{JSON.stringify(toolInput)}</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(),
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", () => {
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],
});
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);
// AppRenderer should be rendered
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
});
it("should close app renderer when close button is clicked", () => {
renderAppsTab({
tools: [mockAppTool],
});
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
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", () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
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", () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
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", () => {
renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
// Now Maximize button should be visible
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", () => {
renderAppsTab({
tools: [mockAppTool],
});
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
// 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", () => {
renderAppsTab({
tools: [mockAppTool],
});
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
// AppRenderer should still be rendered
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
it("should handle various input types and pass them to AppRenderer", () => {
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 }));
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", () => {
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 }));
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", () => {
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 }));
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", () => {
const toolNoFields: Tool = {
name: "noFieldsApp",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://no-fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolNoFields],
});
fireEvent.click(screen.getByText("noFieldsApp"));
// Should see the renderer immediately
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", () => {
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 }));
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();
});
});

Some files were not shown because too many files have changed in this diff Show more