Merge branch 'main' into claude/issue-1053-20260204-1234
This commit is contained in:
commit
f28c64f3eb
24 changed files with 2227 additions and 224 deletions
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
github.event_name == 'pull_request_review_comment' ||
|
||||
github.event_name == 'pull_request_review'
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let prNumber;
|
||||
|
|
|
|||
2
.github/workflows/cli_tests.yml
vendored
2
.github/workflows/cli_tests.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: npm
|
||||
|
|
|
|||
6
.github/workflows/e2e_tests.yml
vendored
6
.github/workflows/e2e_tests.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
id: setup_node
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
# Cache Playwright browsers
|
||||
- name: Cache Playwright browsers
|
||||
id: cache-playwright
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
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
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
run: npm run test:e2e
|
||||
|
||||
- name: Upload Playwright Report and Screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: steps.playwright-tests.conclusion != 'skipped'
|
||||
with:
|
||||
name: playwright-report
|
||||
|
|
|
|||
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
- name: Check formatting
|
||||
run: npx prettier --check .
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: npm
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: npm
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
import { expect } from "vitest";
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as crypto from "crypto";
|
||||
import { getTestMcpServerCommand } from "./test-server-stdio.js";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { Request, Response } from "express";
|
|||
import express from "express";
|
||||
import { createServer as createHttpServer, Server as HttpServer } from "http";
|
||||
import { createServer as createNetServer } from "net";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as z from "zod/v4";
|
||||
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(
|
||||
transport: "http" | "sse",
|
||||
requestedPort?: number,
|
||||
): Promise<number> {
|
||||
const port = requestedPort
|
||||
? await findAvailablePort(requestedPort)
|
||||
: await findAvailablePort(transport === "http" ? 3001 : 3000);
|
||||
|
||||
this.url = `http://localhost:${port}`;
|
||||
const port =
|
||||
requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0;
|
||||
|
||||
if (transport === "http") {
|
||||
return this.startHttp(port);
|
||||
const actualPort = await this.startHttp(port);
|
||||
this.url = `http://localhost:${actualPort}`;
|
||||
return actualPort;
|
||||
} 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
|
||||
this.httpServer = createHttpServer(app);
|
||||
|
||||
// Create StreamableHTTP transport
|
||||
this.transport = new StreamableHTTPServerTransport({});
|
||||
// Create StreamableHTTP transport (stateful so it can handle multiple requests per session)
|
||||
this.transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
|
||||
// Set up Express route to handle MCP requests
|
||||
app.post("/mcp", async (req: Request, res: Response) => {
|
||||
|
|
@ -292,10 +297,12 @@ export class TestServerHttp {
|
|||
// Connect transport to server
|
||||
await this.mcpServer.connect(this.transport);
|
||||
|
||||
// Start listening
|
||||
// Start listening (port 0 = OS assigns a unique port)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer!.listen(port, () => {
|
||||
resolve(port);
|
||||
const assignedPort = (this.httpServer!.address() as { port: number })
|
||||
?.port;
|
||||
resolve(assignedPort ?? port);
|
||||
});
|
||||
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
|
||||
this.transport = undefined;
|
||||
|
||||
// Start listening
|
||||
// Start listening (port 0 = OS assigns a unique port)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.httpServer!.listen(port, () => {
|
||||
resolve(port);
|
||||
const assignedPort = (this.httpServer!.address() as { port: number })
|
||||
?.port;
|
||||
resolve(assignedPort ?? port);
|
||||
});
|
||||
this.httpServer!.on("error", reject);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"test:cli-metadata": "vitest run metadata.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,8 +13,15 @@ module.exports = {
|
|||
tsconfig: "tsconfig.jest.json",
|
||||
},
|
||||
],
|
||||
"^.+\\.m?js$": [
|
||||
"ts-jest",
|
||||
{
|
||||
tsconfig: "tsconfig.jest.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||
transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"],
|
||||
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
|
||||
// Exclude directories and files that don't need to be tested
|
||||
testPathIgnorePatterns: [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-ui/client": "^6.0.0",
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AppWindow,
|
||||
Bell,
|
||||
Files,
|
||||
FolderTree,
|
||||
|
|
@ -75,6 +76,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,
|
||||
|
|
@ -126,6 +128,9 @@ 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[]>([]);
|
||||
|
|
@ -308,11 +313,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
if (!validTabs.includes(originatingTab)) return;
|
||||
|
|
@ -440,11 +447,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
const isValidTab = validTabs.includes(hash);
|
||||
|
|
@ -473,6 +482,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]);
|
||||
|
|
@ -757,11 +773,13 @@ const App = () => {
|
|||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
||||
"apps",
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
if (validTabs.includes(originatingTab)) {
|
||||
|
|
@ -851,22 +869,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) => {
|
||||
|
|
@ -1308,6 +1352,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
|
||||
|
|
@ -1497,6 +1545,19 @@ const App = () => {
|
|||
error={errors.tasks}
|
||||
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 />
|
||||
<PingTab
|
||||
onPingClick={() => {
|
||||
|
|
|
|||
128
client/src/components/AppRenderer.tsx
Normal file
128
client/src/components/AppRenderer.tsx
Normal 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;
|
||||
530
client/src/components/AppsTab.tsx
Normal file
530
client/src/components/AppsTab.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
115
client/src/components/__tests__/AppRenderer.test.tsx
Normal file
115
client/src/components/__tests__/AppRenderer.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
481
client/src/components/__tests__/AppsTab.test.tsx
Normal file
481
client/src/components/__tests__/AppsTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SESSION_KEYS } from "@/lib/constants";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { SESSION_KEYS } from "../../lib/constants";
|
||||
|
||||
const mockOAuthTokens = {
|
||||
access_token: "test_access_token",
|
||||
|
|
@ -55,7 +55,7 @@ import {
|
|||
discoverOAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
||||
import { EMPTY_DEBUGGER_STATE } from "../../lib/auth-types";
|
||||
|
||||
// Mock local auth module
|
||||
jest.mock("@/lib/auth", () => ({
|
||||
|
|
@ -106,7 +106,7 @@ jest.mock("@/lib/auth", () => ({
|
|||
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
|
||||
}));
|
||||
|
||||
import { discoverScopes } from "@/lib/auth";
|
||||
import { discoverScopes } from "../../lib/auth";
|
||||
|
||||
// Type the mocked functions properly
|
||||
const mockDiscoverAuthorizationServerMetadata =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
734
package-lock.json
generated
734
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue