Merge branch 'main' into feature/tool-annotations-display
This commit is contained in:
commit
d1824ca3f9
28 changed files with 2262 additions and 233 deletions
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
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_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;
|
||||||
|
|
|
||||||
2
.github/workflows/cli_tests.yml
vendored
2
.github/workflows/cli_tests.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
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/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
|
||||||
|
|
|
||||||
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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={() => {
|
||||||
|
|
|
||||||
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;
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue