From c8eab10020a60b83b23efa34b25f23551986d007 Mon Sep 17 00:00:00 2001 From: Ronny Rentner Date: Thu, 26 Mar 2026 07:20:08 +0100 Subject: [PATCH] feat: modernize tech stack and enhance UI responsiveness - Upgrade to React 19 and Tailwind CSS v4 - Implement robust resizable/collapsible panel system with reliable double-click toggle - Improve UI stability with synchronized hover states and pixel-perfect resizer alignment - Implement sticky 'Run Tool' controls with IntersectionObserver and scroll delay - Add browser history support for tool parameters using hidden iframe hack - Enhance ListPane with search auto-focus and clear button - Fix proxy server stability and session cleanup logic - Modernize dependency management with packageManager: npm@11.11.0 - Resolve multiple build and type errors across the monorepo --- .gitignore | 3 + .npmrc | 2 + .prettierignore | 10 +- cli/__tests__/helpers/cli-runner.ts | 2 +- cli/__tests__/helpers/test-server-http.ts | 440 +- cli/__tests__/helpers/test-server-stdio.ts | 4 +- cli/package.json | 8 +- cli/src/error-handler.ts | 3 + cli/src/transport.ts | 18 +- client/bin/start.js | 17 +- client/jest.config.cjs | 11 + client/package.json | 90 +- client/postcss.config.js | 6 - client/src/App.css | 8 +- client/src/App.tsx | 901 +- client/src/__tests__/App.config.test.tsx | 12 - client/src/__tests__/App.routing.test.tsx | 12 - .../__tests__/App.samplingNavigation.test.tsx | 28 +- .../__tests__/App.toolsAppsPrefill.test.tsx | 12 - client/src/components/AppsTab.tsx | 12 +- client/src/components/DynamicJsonForm.tsx | 8 +- client/src/components/ElicitationRequest.tsx | 5 +- .../components/HistoryAndNotifications.tsx | 43 +- client/src/components/ListPane.tsx | 125 +- client/src/components/PingTab.tsx | 4 +- client/src/components/PromptsTab.tsx | 221 +- client/src/components/ResourcesTab.tsx | 397 +- client/src/components/Sidebar.tsx | 2 +- client/src/components/TasksTab.tsx | 306 +- client/src/components/ToolsTab.tsx | 1336 +- .../__tests__/ElicitationRequest.test.tsx | 40 + .../components/__tests__/ListPane.test.tsx | 83 +- .../components/__tests__/ToolsTab.test.tsx | 86 +- client/src/components/ui/button.tsx | 8 +- client/src/components/ui/input.tsx | 6 +- client/src/components/ui/resizable.tsx | 81 + client/src/components/ui/separator.tsx | 29 + client/src/components/ui/sheet.tsx | 141 + client/src/components/ui/sidebar.tsx | 779 + client/src/components/ui/skeleton.tsx | 15 + client/src/components/ui/tabs.tsx | 2 +- client/src/components/ui/toaster.tsx | 6 +- client/src/components/ui/tooltip.tsx | 22 +- client/src/hooks/use-mobile.tsx | 21 + client/src/hooks/use-panel-toggle.ts | 45 + client/src/index.css | 123 +- client/src/lib/configurationTypes.ts | 5 + client/src/lib/constants.ts | 7 + client/src/lib/hooks/useDraggablePane.ts | 105 - client/src/setupTests.ts | 28 + client/src/setupVitest.ts | 58 + .../src/utils/__tests__/schemaUtils.test.ts | 50 +- client/src/utils/schemaUtils.ts | 36 +- client/tailwind.config.js | 58 - client/vite.config.ts | 3 +- client/vitest.config.ts | 17 + package-lock.json | 15462 +++++++--------- package.json | 23 +- server/package.json | 10 +- server/src/index.ts | 97 +- tests/helpers/jest-file-reporter.cjs | 50 + 61 files changed, 10651 insertions(+), 10891 deletions(-) delete mode 100644 client/postcss.config.js create mode 100644 client/src/components/ui/resizable.tsx create mode 100644 client/src/components/ui/separator.tsx create mode 100644 client/src/components/ui/sheet.tsx create mode 100644 client/src/components/ui/sidebar.tsx create mode 100644 client/src/components/ui/skeleton.tsx create mode 100644 client/src/hooks/use-mobile.tsx create mode 100644 client/src/hooks/use-panel-toggle.ts delete mode 100644 client/src/lib/hooks/useDraggablePane.ts create mode 100644 client/src/setupTests.ts create mode 100644 client/src/setupVitest.ts delete mode 100644 client/tailwind.config.js create mode 100644 client/vitest.config.ts create mode 100644 tests/helpers/jest-file-reporter.cjs diff --git a/.gitignore b/.gitignore index 230d72d41..9e360fc80 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ client/test-results/ client/e2e/test-results/ mcp.json .claude/settings.local.json +*.log +mcp-logs/ +client/coverage/ diff --git a/.npmrc b/.npmrc index 1a3d62095..99e0f7660 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,4 @@ registry="https://registry.npmjs.org/" @modelcontextprotocol:registry="https://registry.npmjs.org/" +package-lock=true +save=true diff --git a/.prettierignore b/.prettierignore index 167a7cb48..a1e85126f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,12 @@ server/build CODE_OF_CONDUCT.md SECURITY.md mcp.json -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +# Logs and transient data +mcp-logs/ +*.log +server/*.log +server/*.yaml +server/mcp-logs/ +react-resizable-panels-src/jest-source/ diff --git a/cli/__tests__/helpers/cli-runner.ts b/cli/__tests__/helpers/cli-runner.ts index 073aa9ae4..2c6c5fb73 100644 --- a/cli/__tests__/helpers/cli-runner.ts +++ b/cli/__tests__/helpers/cli-runner.ts @@ -31,7 +31,7 @@ export async function runCli( const child = spawn("node", [CLI_PATH, ...args], { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd, - env: { ...process.env, ...options.env }, + env: { DEBUG: "true", ...process.env, ...options.env }, signal: options.signal, // Kill child process tree on exit detached: false, diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts index d5eadc3ff..35e9192f2 100644 --- a/cli/__tests__/helpers/test-server-http.ts +++ b/cli/__tests__/helpers/test-server-http.ts @@ -1,13 +1,20 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { SetLevelRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + SetLevelRequestSchema, + ListToolsRequestSchema, + CallToolRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; 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 { randomUUID } from "node:crypto"; import type { ServerConfig } from "./test-fixtures.js"; export interface RecordedRequest { @@ -19,19 +26,15 @@ export interface RecordedRequest { timestamp: number; } -/** - * Find an available port starting from the given port - */ async function findAvailablePort(startPort: number): Promise { return new Promise((resolve, reject) => { const server = createNetServer(); - server.listen(startPort, () => { + server.listen(startPort, "127.0.0.1", () => { const port = (server.address() as { port: number })?.port; server.close(() => resolve(port || startPort)); }); server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { - // Try next port findAvailablePort(startPort + 1) .then(resolve) .catch(reject); @@ -42,9 +45,6 @@ async function findAvailablePort(startPort: number): Promise { }); } -/** - * Extract headers from Express request - */ function extractHeaders(req: Request): Record { const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { @@ -57,17 +57,15 @@ function extractHeaders(req: Request): Record { return headers; } -// With this test server, your test can hold an instance and you can get the server's recorded message history at any time. -// export class TestServerHttp { - private mcpServer: McpServer; + private server: Server; private config: ServerConfig; private recordedRequests: RecordedRequest[] = []; private httpServer?: HttpServer; - private transport?: StreamableHTTPServerTransport | SSEServerTransport; private url?: string; - private currentRequestHeaders?: Record; private currentLogLevel: string | null = null; + private webAppTransports = new Map(); + private currentRequestHeaders: Record = {}; constructor(config: ServerConfig) { this.config = config; @@ -77,114 +75,108 @@ export class TestServerHttp { prompts?: {}; logging?: {}; } = {}; + if (config.tools !== undefined) capabilities.tools = {}; + if (config.resources !== undefined) capabilities.resources = {}; + if (config.prompts !== undefined) capabilities.prompts = {}; + if (config.logging === true) capabilities.logging = {}; - // Only include capabilities for features that are present in config - if (config.tools !== undefined) { - capabilities.tools = {}; - } - if (config.resources !== undefined) { - capabilities.resources = {}; - } - if (config.prompts !== undefined) { - capabilities.prompts = {}; - } - if (config.logging === true) { - capabilities.logging = {}; - } - - this.mcpServer = new McpServer(config.serverInfo, { - capabilities, - }); - + this.server = new Server(config.serverInfo, { capabilities }); this.setupHandlers(); - if (config.logging === true) { - this.setupLoggingHandler(); - } } private setupHandlers() { - // Set up tools - if (this.config.tools && this.config.tools.length > 0) { - for (const tool of this.config.tools) { - this.mcpServer.registerTool( - tool.name, - { - description: tool.description, - inputSchema: tool.inputSchema, - }, - async (args) => { - const result = await tool.handler(args as Record); - return { - content: [{ type: "text", text: JSON.stringify(result) }], - }; + // Tools + if (this.config.tools !== undefined) { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: (this.config.tools || []).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: "object", + properties: tool.inputSchema || {}, }, + })), + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = this.config.tools?.find( + (t) => t.name === request.params.name, ); - } + if (!tool) throw new Error(`Tool not found: ${request.params.name}`); + const result = await tool.handler(request.params.arguments || {}); + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + }); } - // Set up resources - if (this.config.resources && this.config.resources.length > 0) { - for (const resource of this.config.resources) { - this.mcpServer.registerResource( - resource.name, - resource.uri, - { - description: resource.description, - mimeType: resource.mimeType, - }, - async () => { - return { - contents: [ - { - uri: resource.uri, - mimeType: resource.mimeType || "text/plain", - text: resource.text || "", - }, - ], - }; - }, - ); - } + // Resources + if (this.config.resources !== undefined) { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: this.config.resources || [], + })); + + this.server.setRequestHandler( + ReadResourceRequestSchema, + async (request) => { + const resource = this.config.resources?.find( + (r) => r.uri === request.params.uri, + ); + if (!resource) + throw new Error(`Resource not found: ${request.params.uri}`); + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType || "text/plain", + text: resource.text || "", + }, + ], + }; + }, + ); } - // Set up prompts - if (this.config.prompts && this.config.prompts.length > 0) { - for (const prompt of this.config.prompts) { - this.mcpServer.registerPrompt( - prompt.name, - { - description: prompt.description, - argsSchema: prompt.argsSchema, - }, - async (args) => { - // Return a simple prompt response - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `Prompt: ${prompt.name}${args ? ` with args: ${JSON.stringify(args)}` : ""}`, - }, - }, - ], - }; - }, + // Prompts + if (this.config.prompts !== undefined) { + this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: (this.config.prompts || []).map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: (prompt.argsSchema + ? Object.entries(prompt.argsSchema).map(([name, schema]) => ({ + name, + description: (schema as any).description, + required: !(schema as any).isOptional?.(), + })) + : []) as any, + })), + })); + + this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const prompt = this.config.prompts?.find( + (p) => p.name === request.params.name, ); - } + if (!prompt) + throw new Error(`Prompt not found: ${request.params.name}`); + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: `Prompt: ${prompt.name}${request.params.arguments ? ` with args: ${JSON.stringify(request.params.arguments)}` : ""}`, + }, + }, + ], + }; + }); } - } - private setupLoggingHandler() { - // Intercept logging/setLevel requests to track the level - this.mcpServer.server.setRequestHandler( - SetLevelRequestSchema, - async (request) => { + if (this.config.logging === true) { + this.server.setRequestHandler(SetLevelRequestSchema, async (request) => { this.currentLogLevel = request.params.level; - // Return empty result as per MCP spec return {}; - }, - ); + }); + } } /** @@ -192,13 +184,13 @@ export class TestServerHttp { * 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", + transportType: "http" | "sse", requestedPort?: number, ): Promise { const port = requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0; - if (transport === "http") { + if (transportType === "http") { const actualPort = await this.startHttp(port); this.url = `http://localhost:${actualPort}`; return actualPort; @@ -212,94 +204,43 @@ export class TestServerHttp { private async startHttp(port: number): Promise { const app = express(); app.use(express.json()); - - // Create HTTP server this.httpServer = createHttpServer(app); - // 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) => { - // Capture headers for this request + const sessionId = req.headers["mcp-session-id"] as string | undefined; this.currentRequestHeaders = extractHeaders(req); - try { - await (this.transport as StreamableHTTPServerTransport).handleRequest( - req, - res, - req.body, - ); - } catch (error) { - res.status(500).json({ - error: error instanceof Error ? error.message : String(error), - }); - } - }); - - // Intercept messages to record them - const originalOnMessage = this.transport.onmessage; - this.transport.onmessage = async (message) => { - const timestamp = Date.now(); - const method = - "method" in message && typeof message.method === "string" - ? message.method - : "unknown"; - const params = "params" in message ? message.params : undefined; - - try { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - // Let the server handle the message - if (originalOnMessage) { - await originalOnMessage.call(this.transport, message); + const recorded: RecordedRequest = { + method: req.body?.method || "unknown", + params: req.body?.params, + headers: this.currentRequestHeaders, + metadata: req.body?.params?._meta, + timestamp: Date.now(), + response: { processed: true }, + }; + this.recordedRequests.push(recorded); + + if (sessionId) { + const transport = this.webAppTransports.get(sessionId); + if (!transport) { + res.status(404).end("Session not found"); + } else { + await transport.handleRequest(req, res, req.body); } - - // Record successful request (response will be sent by transport) - // Note: We can't easily capture the response here, so we'll record - // that the request was processed - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { processed: true }, - timestamp, - }); - } catch (error) { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - // Record error - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { - error: error instanceof Error ? error.message : String(error), - }, - timestamp, + } else { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (id) => + this.webAppTransports.set(id, transport), + onsessionclosed: (id) => this.webAppTransports.delete(id), }); - throw error; + await this.server.connect(transport); + await transport.handleRequest(req, res, req.body); } - }; - - // Connect transport to server - await this.mcpServer.connect(this.transport); + }); - // Start listening (port 0 = OS assigns a unique port) return new Promise((resolve, reject) => { - this.httpServer!.listen(port, () => { + this.httpServer!.listen(port, "127.0.0.1", () => { const assignedPort = (this.httpServer!.address() as { port: number }) ?.port; resolve(assignedPort ?? port); @@ -311,76 +252,25 @@ export class TestServerHttp { private async startSse(port: number): Promise { const app = express(); app.use(express.json()); - - // Create HTTP server this.httpServer = createHttpServer(app); - // For SSE, we need to set up an Express route that creates the transport per request - // This is a simplified version - SSE transport is created per connection app.get("/mcp", async (req: Request, res: Response) => { this.currentRequestHeaders = extractHeaders(req); - const sseTransport = new SSEServerTransport("/mcp", res); - - // Intercept messages - const originalOnMessage = sseTransport.onmessage; - sseTransport.onmessage = async (message) => { - const timestamp = Date.now(); - const method = - "method" in message && typeof message.method === "string" - ? message.method - : "unknown"; - const params = "params" in message ? message.params : undefined; - - try { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - if (originalOnMessage) { - await originalOnMessage.call(sseTransport, message); - } - - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { processed: true }, - timestamp, - }); - } catch (error) { - // Extract metadata from params if present - const metadata = - params && typeof params === "object" && "_meta" in params - ? ((params as any)._meta as Record) - : undefined; - - this.recordedRequests.push({ - method, - params, - headers: { ...this.currentRequestHeaders }, - metadata: metadata ? { ...metadata } : undefined, - response: { - error: error instanceof Error ? error.message : String(error), - }, - timestamp, - }); - throw error; - } + const recorded: RecordedRequest = { + method: "sse-connect", + headers: this.currentRequestHeaders, + timestamp: Date.now(), + response: { processed: true }, }; + this.recordedRequests.push(recorded); - await this.mcpServer.connect(sseTransport); - await sseTransport.start(); + const transport = new SSEServerTransport("/mcp", res); + await this.server.connect(transport); + await transport.start(); }); - // Note: SSE transport is created per request, so we don't store a single instance - this.transport = undefined; - - // Start listening (port 0 = OS assigns a unique port) return new Promise((resolve, reject) => { - this.httpServer!.listen(port, () => { + this.httpServer!.listen(port, "127.0.0.1", () => { const assignedPort = (this.httpServer!.address() as { port: number }) ?.port; resolve(assignedPort ?? port); @@ -389,64 +279,30 @@ export class TestServerHttp { }); } - /** - * Stop the server - */ async stop(): Promise { - await this.mcpServer.close(); - - if (this.transport) { - await this.transport.close(); - this.transport = undefined; - } - + await this.server.close(); + for (const t of this.webAppTransports.values()) await t.close(); if (this.httpServer) { return new Promise((resolve) => { - // Force close all connections - this.httpServer!.closeAllConnections?.(); - this.httpServer!.close(() => { - this.httpServer = undefined; - resolve(); - }); + this.httpServer!.close(() => resolve()); }); } } - /** - * Get all recorded requests - */ - getRecordedRequests(): RecordedRequest[] { - return [...this.recordedRequests]; - } - - /** - * Clear recorded requests - */ - clearRecordings(): void { - this.recordedRequests = []; - } - - /** - * Get the server URL - */ getUrl(): string { - if (!this.url) { - throw new Error("Server not started"); - } + if (!this.url) throw new Error("Server not started"); return this.url; } - /** - * Get the most recent log level that was set - */ getCurrentLogLevel(): string | null { return this.currentLogLevel; } + + getRecordedRequests(): RecordedRequest[] { + return [...this.recordedRequests]; + } } -/** - * Create an HTTP/SSE MCP test server - */ export function createTestServerHttp(config: ServerConfig): TestServerHttp { return new TestServerHttp(config); } diff --git a/cli/__tests__/helpers/test-server-stdio.ts b/cli/__tests__/helpers/test-server-stdio.ts index 7fe6a1c47..b24304e2a 100644 --- a/cli/__tests__/helpers/test-server-stdio.ts +++ b/cli/__tests__/helpers/test-server-stdio.ts @@ -213,8 +213,10 @@ export function getTestMcpServerPath(): string { * Get the command and args to run the test MCP server */ export function getTestMcpServerCommand(): { command: string; args: string[] } { + const projectRoot = path.resolve(__dirname, "../../../"); + const tsxPath = path.resolve(projectRoot, "node_modules", ".bin", "tsx"); return { - command: "tsx", + command: tsxPath, args: [getTestMcpServerPath()], }; } diff --git a/cli/package.json b/cli/package.json index 78092335e..9b77b7eb0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,12 +28,12 @@ "devDependencies": { "@types/express": "^5.0.0", "tsx": "^4.7.0", - "vitest": "^4.0.17" + "vitest": "^4.0.18" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", - "commander": "^13.1.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "commander": "^14.0.3", "express": "^5.2.1", - "spawn-rx": "^5.1.2" + "spawn-rx": "5.1.2" } } diff --git a/cli/src/error-handler.ts b/cli/src/error-handler.ts index 972577453..f3d583c06 100644 --- a/cli/src/error-handler.ts +++ b/cli/src/error-handler.ts @@ -14,6 +14,9 @@ function formatError(error: unknown): string { export function handleError(error: unknown): never { const errorMessage = formatError(error); + if (process.env.DEBUG) { + console.error("Full Error:", error); + } console.error(errorMessage); process.exit(1); diff --git a/cli/src/transport.ts b/cli/src/transport.ts index 84af393b9..d8900dc66 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -76,14 +76,16 @@ export function createTransport(options: TransportOptions): Transport { } if (transportType === "http") { - const transportOptions = options.headers - ? { - requestInit: { - headers: options.headers, - }, - } - : undefined; - return new StreamableHTTPClientTransport(url, transportOptions); + const headers = { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + ...options.headers, + }; + return new StreamableHTTPClientTransport(url, { + requestInit: { + headers, + }, + }); } throw new Error(`Unsupported transport type: ${transportType}`); diff --git a/client/bin/start.js b/client/bin/start.js index 7e7e013af..3bc3f8105 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -36,9 +36,15 @@ async function startDevServer(serverOptions) { abort, transport, serverUrl, + command, + mcpServerArgs, } = serverOptions; const serverCommand = "npx"; const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; + // Forward the MCP server command and arguments to the proxy server in dev mode + if (command) serverArgs.push(`--command=${command}`); + if (mcpServerArgs.length > 0) + serverArgs.push(`--args=${mcpServerArgs.join(" ")}`); const isWindows = process.platform === "win32"; const spawnOptions = { @@ -268,9 +274,9 @@ async function main() { } else { envVars[envVar] = ""; } - } else if (!command && !isDev) { + } else if (!command) { command = arg; - } else if (!isDev) { + } else { mcpServerArgs.push(arg); } } @@ -287,7 +293,12 @@ async function main() { // Use provided token from environment or generate a new one const sessionToken = process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); - const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; + + const host = process.env.HOST || "localhost"; + const isLocal = host === "localhost" || host === "127.0.0.1"; + const authDisabled = + process.env.DANGEROUSLY_OMIT_AUTH === "true" || + (isLocal && process.env.REQUIRE_AUTH !== "true"); const abort = new AbortController(); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 704852961..22ab56ee3 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,3 +1,5 @@ +const path = require("path"); + module.exports = { preset: "ts-jest", testEnvironment: "jest-fixed-jsdom", @@ -5,6 +7,15 @@ module.exports = { "^@/(.*)$": "/src/$1", "\\.css$": "/src/__mocks__/styleMock.js", }, + setupFilesAfterEnv: ["/src/setupTests.ts"], + silent: true, + reporters: [ + "summary", + [ + path.resolve(__dirname, "../tests/helpers/jest-file-reporter.cjs"), + { filename: "test-failures.log" }, + ], + ], transform: { "^.+\\.tsx?$": [ "ts-jest", diff --git a/client/package.json b/client/package.json index 66732f399..ec14cb757 100644 --- a/client/package.json +++ b/client/package.json @@ -28,58 +28,64 @@ "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", - "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.3", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-slot": "^1.1.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-tooltip": "^1.1.8", - "ajv": "^6.12.6", - "class-variance-authority": "^0.7.0", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.4", - "lucide-react": "^0.523.0", - "pkce-challenge": "^4.1.0", + "cmdk": "^1.1.1", + "lucide-react": "^0.575.0", + "pkce-challenge": "^6.0.0", "prismjs": "^1.30.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-resizable-panels": "^4.7.0", "react-simple-code-editor": "^0.14.1", "serve-handler": "^6.1.6", - "tailwind-merge": "^2.5.3", - "zod": "^3.25.76" + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { - "@eslint/js": "^9.11.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@types/jest": "^29.5.14", - "@types/node": "^22.17.0", - "@types/prismjs": "^1.26.5", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.0", + "@eslint/js": "^9.39.3", + "@tailwindcss/vite": "^4.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jest": "^30.0.0", + "@types/node": "^25.3.3", + "@types/prismjs": "^1.26.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@types/serve-handler": "^6.1.4", - "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.20", + "@vitejs/plugin-react": "^5.1.4", + "autoprefixer": "^10.4.27", "co": "^4.6.0", - "eslint": "^9.11.1", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.9.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jest-fixed-jsdom": "^0.0.9", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "jest-fixed-jsdom": "^0.0.11", + "jsdom": "^28.1.0", "postcss": "^8.5.6", - "tailwindcss": "^3.4.13", + "tailwindcss": "^4.2.1", "tailwindcss-animate": "^1.0.7", - "ts-jest": "^29.4.0", - "typescript": "^5.5.3", - "typescript-eslint": "^8.38.0", - "vite": "^7.1.11" + "ts-jest": "^29.4.6", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", + "vitest": "^4.0.18" } } diff --git a/client/postcss.config.js b/client/postcss.config.js deleted file mode 100644 index 2aa7205d4..000000000 --- a/client/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/client/src/App.css b/client/src/App.css index 0d669ffa5..27cd6dacf 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,3 +1,7 @@ +.scrollable-resizable-panel { + overflow-y: auto !important; +} + .logo { height: 6em; padding: 1.5em; @@ -8,7 +12,7 @@ filter: drop-shadow(0 0 2em #646cffaa); } .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + filter: drop_shadow(0 0 2em #61dafbaa); } @keyframes logo-spin { @@ -21,7 +25,7 @@ } @media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { + a:nth-of_type(2) .logo { animation: logo-spin infinite 20s linear; } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 12e9a7bd0..902a31218 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -44,12 +44,17 @@ import React, { useState, } from "react"; import { useConnection } from "./lib/hooks/useConnection"; -import { - useDraggablePane, - useDraggableSidebar, -} from "./lib/hooks/useDraggablePane"; +import { usePanelToggle } from "./hooks/use-panel-toggle"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + HorizontalHandle, + VerticalHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@/components/ui/resizable"; +import { SidebarProvider } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; import { AppWindow, @@ -210,6 +215,16 @@ const App = () => { return localStorage.getItem("lastOauthClientSecret") || ""; }); + const { + defaultLayout: mainHorizontalDefaultLayout, + onLayoutChanged: onMainHorizontalLayoutChanged, + } = useDefaultLayout({ id: "persistence-main-horizontal" }); + + const { + defaultLayout: mainVerticalDefaultLayout, + onLayoutChanged: onMainVerticalLayoutChanged, + } = useDefaultLayout({ id: "persistence-main-vertical" }); + // Custom headers state with migration from legacy auth const [customHeaders, setCustomHeaders] = useState(() => { const savedHeaders = localStorage.getItem("lastCustomHeaders"); @@ -316,6 +331,8 @@ const App = () => { const [nextTaskCursor, setNextTaskCursor] = useState(); const progressTokenRef = useRef(0); const prefilledAppsToolCallIdRef = useRef(0); + const { panelRef: sidebarRef, toggle: toggleSidebar } = usePanelToggle("20%"); + const { panelRef: historyRef, toggle: toggleHistory } = usePanelToggle("30%"); const [activeTab, setActiveTab] = useState(() => { const hash = window.location.hash.slice(1); @@ -358,13 +375,6 @@ const App = () => { }, 100); }; - const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); - const { - width: sidebarWidth, - isDragging: isSidebarDragging, - handleDragStart: handleSidebarDragStart, - } = useDraggableSidebar(320); - const selectedTaskRef = useRef(null); useEffect(() => { selectedTaskRef.current = selectedTask; @@ -945,7 +955,7 @@ const App = () => { method: "resources/subscribe" as const, params: { uri }, }, - z.object({}), + z.object({}) as unknown as AnySchema, "resources", ); const clone = new Set(resourceSubscriptions); @@ -961,7 +971,7 @@ const App = () => { method: "resources/unsubscribe" as const, params: { uri }, }, - z.object({}), + z.object({}) as unknown as AnySchema, "resources", ); const clone = new Set(resourceSubscriptions); @@ -1058,6 +1068,8 @@ const App = () => { typeof res.task === "object" && "taskId" in res.task; + let finalResult: CompatibilityCallToolResult; + if (runAsTask && isTaskResult(response)) { const taskId = response.task.taskId; const pollInterval = response.task.pollInterval; @@ -1180,16 +1192,28 @@ const App = () => { } } setIsPollingTask(false); - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); - return latestToolResult; + finalResult = latestToolResult; + } else { + finalResult = response as CompatibilityCallToolResult; + setToolResult(finalResult); + } + + // Handle Apps Tab prefilled tool call + const calledTool = tools.find((t) => t.name === name); + if (calledTool && hasAppResourceUri(calledTool)) { + setPrefilledAppsToolCall({ + id: ++prefilledAppsToolCallIdRef.current, + toolName: name, + params: cloneToolParams(params), + result: finalResult, + }); } else { - const directResult = response as CompatibilityCallToolResult; - setToolResult(directResult); - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); - return directResult; + setPrefilledAppsToolCall(null); } + + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); + return finalResult; } catch (e) { const toolResult: CompatibilityCallToolResult = { content: [ @@ -1201,6 +1225,7 @@ const App = () => { isError: true, }; setToolResult(toolResult); + setPrefilledAppsToolCall(null); // Clear validation errors - tool execution errors are shown in ToolResults setErrors((prev) => ({ ...prev, tools: null })); return toolResult; @@ -1252,7 +1277,7 @@ const App = () => { method: "logging/setLevel" as const, params: { level }, }, - z.object({}), + z.object({}) as unknown as AnySchema, ); setLogLevel(level); }; @@ -1291,432 +1316,428 @@ const App = () => { } return ( -
-
+ - -
-
-
-
- {mcpClient ? ( - { - setActiveTab(value); - window.location.hash = value; - }} + + + + + + + - - - - Resources - - - - Prompts - - - - Tools - - - - Tasks - - - - Apps - - - - Ping - - - - Sampling - {pendingSampleRequests.length > 0 && ( - - {pendingSampleRequests.length} - - )} - - - - Elicitations - {pendingElicitationRequests.length > 0 && ( - - {pendingElicitationRequests.length} - - )} - - - - Roots - - - - Auth - - - - Metadata - - - -
- {!serverCapabilities?.resources && - !serverCapabilities?.prompts && - !serverCapabilities?.tools ? ( - <> -
-

- The connected server does not support any MCP - capabilities -

+
+ {mcpClient ? ( + { + setActiveTab(value); + window.location.hash = value; + }} + > + + + + Resources + + + + Prompts + + + + Tools + + + + Tasks + + + + Apps + + + + Ping + + + + Sampling + {pendingSampleRequests.length > 0 && ( + + {pendingSampleRequests.length} + + )} + + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + + + + Roots + + + + Auth + + + + Metadata + + + +
+ {!serverCapabilities?.resources && + !serverCapabilities?.prompts && + !serverCapabilities?.tools && + !serverCapabilities?.tasks ? ( + <> +
+

+ The connected server does not support any MCP + capabilities +

+
+ { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + ) : ( + <> + { + clearError("resources"); + listResources(); + }} + clearResources={() => { + setResources([]); + setNextResourceCursor(undefined); + }} + listResourceTemplates={() => { + clearError("resources"); + listResourceTemplates(); + }} + clearResourceTemplates={() => { + setResourceTemplates([]); + setNextResourceTemplateCursor(undefined); + }} + readResource={(uri) => { + clearError("resources"); + readResource(uri); + }} + selectedResource={selectedResource} + setSelectedResource={(resource) => { + clearError("resources"); + setSelectedResource(resource); + }} + resourceSubscriptionsSupported={ + serverCapabilities?.resources?.subscribe || false + } + resourceSubscriptions={resourceSubscriptions} + subscribeToResource={(uri) => { + clearError("resources"); + subscribeToResource(uri); + }} + unsubscribeFromResource={(uri) => { + clearError("resources"); + unsubscribeFromResource(uri); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + resourceContent={resourceContent} + nextCursor={nextResourceCursor} + nextTemplateCursor={nextResourceTemplateCursor} + error={errors.resources} + /> + { + clearError("prompts"); + listPrompts(); + }} + clearPrompts={() => { + setPrompts([]); + setNextPromptCursor(undefined); + }} + getPrompt={(name, args) => { + clearError("prompts"); + getPrompt(name, args); + }} + selectedPrompt={selectedPrompt} + setSelectedPrompt={(prompt) => { + clearError("prompts"); + setSelectedPrompt(prompt); + setPromptContent(""); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + promptContent={promptContent} + nextCursor={nextPromptCursor} + error={errors.prompts} + /> + { + clearError("tools"); + listTools(); + }} + clearTools={() => { + setTools([]); + setNextToolCursor(undefined); + cacheToolOutputSchemas([]); + }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + return await callTool( + name, + params, + metadata, + runAsTask, + ); + }} + selectedTool={selectedTool} + setSelectedTool={(tool) => { + clearError("tools"); + setSelectedTool(tool); + setToolResult(null); + }} + toolResult={toolResult} + isPollingTask={isPollingTask} + nextCursor={nextToolCursor} + error={errors.tools} + resourceContent={resourceContentMap} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} + config={config} + serverSupportsTaskRequests={ + !!serverCapabilities?.tasks + } + /> + { + clearError("tasks"); + listTasks(); + }} + clearTasks={() => { + setTasks([]); + setNextTaskCursor(undefined); + }} + cancelTask={cancelTask} + selectedTask={selectedTask} + setSelectedTask={(task) => { + clearError("tasks"); + setSelectedTask(task); + }} + error={errors.tasks} + nextCursor={nextTaskCursor} + /> + { + clearError("tools"); + listTools(); + }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + return callTool( + name, + params, + metadata, + runAsTask, + ); + }} + prefilledToolCall={prefilledAppsToolCall} + onPrefilledToolCallConsumed={(callId) => { + setPrefilledAppsToolCall((prev) => + prev?.id === callId ? null : prev, + ); + }} + error={errors.tools} + mcpClient={mcpClient} + onNotification={(notification) => { + setNotifications((prev) => [ + ...prev, + notification as ServerNotification, + ]); + }} + /> + + { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + + + + + + )}
- { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - ) : ( - <> - { - clearError("resources"); - listResources(); - }} - clearResources={() => { - setResources([]); - setNextResourceCursor(undefined); - }} - listResourceTemplates={() => { - clearError("resources"); - listResourceTemplates(); - }} - clearResourceTemplates={() => { - setResourceTemplates([]); - setNextResourceTemplateCursor(undefined); - }} - readResource={(uri) => { - clearError("resources"); - readResource(uri); - }} - selectedResource={selectedResource} - setSelectedResource={(resource) => { - clearError("resources"); - setSelectedResource(resource); - }} - resourceSubscriptionsSupported={ - serverCapabilities?.resources?.subscribe || false - } - resourceSubscriptions={resourceSubscriptions} - subscribeToResource={(uri) => { - clearError("resources"); - subscribeToResource(uri); - }} - unsubscribeFromResource={(uri) => { - clearError("resources"); - unsubscribeFromResource(uri); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - resourceContent={resourceContent} - nextCursor={nextResourceCursor} - nextTemplateCursor={nextResourceTemplateCursor} - error={errors.resources} - /> - { - clearError("prompts"); - listPrompts(); - }} - clearPrompts={() => { - setPrompts([]); - setNextPromptCursor(undefined); - }} - getPrompt={(name, args) => { - clearError("prompts"); - getPrompt(name, args); - }} - selectedPrompt={selectedPrompt} - setSelectedPrompt={(prompt) => { - clearError("prompts"); - setSelectedPrompt(prompt); - setPromptContent(""); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - promptContent={promptContent} - nextCursor={nextPromptCursor} - error={errors.prompts} - /> - { - clearError("tools"); - listTools(); - }} - clearTools={() => { - setTools([]); - setNextToolCursor(undefined); - cacheToolOutputSchemas([]); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - const result = await callTool( - name, - params, - metadata, - runAsTask, - ); - const calledTool = tools.find( - (tool) => tool.name === name, - ); - if (calledTool && hasAppResourceUri(calledTool)) { - setPrefilledAppsToolCall({ - id: ++prefilledAppsToolCallIdRef.current, - toolName: name, - params: cloneToolParams(params), - result, - }); - } else { - setPrefilledAppsToolCall(null); - } - return result; - }} - selectedTool={selectedTool} - setSelectedTool={(tool) => { - clearError("tools"); - setSelectedTool(tool); - setToolResult(null); - }} - toolResult={toolResult} - isPollingTask={isPollingTask} - nextCursor={nextToolCursor} - error={errors.tools} - resourceContent={resourceContentMap} - onReadResource={(uri: string) => { - clearError("resources"); - readResource(uri); - }} - /> - { - clearError("tasks"); - listTasks(); - }} - clearTasks={() => { - setTasks([]); - setNextTaskCursor(undefined); - }} - cancelTask={cancelTask} - selectedTask={selectedTask} - setSelectedTask={(task) => { - clearError("tasks"); - setSelectedTask(task); - }} - error={errors.tasks} - nextCursor={nextTaskCursor} - /> - { - clearError("tools"); - listTools(); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - return callTool(name, params, metadata, runAsTask); - }} - prefilledToolCall={prefilledAppsToolCall} - onPrefilledToolCallConsumed={(callId) => { - setPrefilledAppsToolCall((prev) => - prev?.id === callId ? null : prev, - ); - }} - error={errors.tools} - mcpClient={mcpClient} - onNotification={(notification) => { - setNotifications((prev) => [...prev, notification]); - }} - /> - - { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - - +
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > - - + + ) : ( +
+

+ Connect to an MCP server to start inspecting +

+
+

+ Need to configure authentication? +

+ +
+
)}
- - ) : isAuthDebuggerVisible ? ( - (window.location.hash = value)} + + + - - - ) : ( -
-

- Connect to an MCP server to start inspecting -

-
-

- Need to configure authentication? -

- +
+
-
- )} -
-
-
-
-
-
- -
-
-
-
+ + + + + ); }; diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx index 7458c2055..02bc54009 100644 --- a/client/src/__tests__/App.config.test.tsx +++ b/client/src/__tests__/App.config.test.tsx @@ -59,18 +59,6 @@ jest.mock("../lib/hooks/useConnection", () => ({ }), })); -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - jest.mock("../components/Sidebar", () => ({ __esModule: true, default: () =>
Sidebar
, diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx index 4713bef9a..836480feb 100644 --- a/client/src/__tests__/App.routing.test.tsx +++ b/client/src/__tests__/App.routing.test.tsx @@ -65,18 +65,6 @@ const connectedConnectionState = { }; // Mock required dependencies, but unrelated to routing. -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - jest.mock("../components/Sidebar", () => ({ __esModule: true, default: () =>
Sidebar
, diff --git a/client/src/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx index 70a42a92f..1b85efc1c 100644 --- a/client/src/__tests__/App.samplingNavigation.test.tsx +++ b/client/src/__tests__/App.samplingNavigation.test.tsx @@ -59,23 +59,27 @@ jest.mock("../utils/configUtils", () => ({ saveInspectorConfig: jest.fn(), })); -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - jest.mock("../components/Sidebar", () => ({ __esModule: true, default: () =>
Sidebar
, })); +jest.mock("@/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: any) => ( +
{children}
+ ), + ResizablePanel: ({ children }: any) => ( +
{children}
+ ), + ResizableHandle: () =>
, + VerticalHandle: () =>
, + HorizontalHandle: () =>
, + useDefaultLayout: () => ({ + defaultLayout: undefined, + onLayoutChanged: jest.fn(), + }), +})); + jest.mock("../lib/hooks/useToast", () => ({ useToast: () => ({ toast: jest.fn() }), })); diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx index 9239f29db..3862031fc 100644 --- a/client/src/__tests__/App.toolsAppsPrefill.test.tsx +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -58,18 +58,6 @@ jest.mock("../utils/configUtils", () => ({ saveInspectorConfig: jest.fn(), })); -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - jest.mock("../components/Sidebar", () => ({ __esModule: true, default: () =>
Sidebar
, diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx index 2b6d75358..2956ebfaa 100644 --- a/client/src/components/AppsTab.tsx +++ b/client/src/components/AppsTab.tsx @@ -537,9 +537,9 @@ const AppsTab = ({ ) : prop.type === "object" || prop.type === "array" ? ( - (formRefs.current[key] = ref) - } + ref={(ref) => { + formRefs.current[key] = ref; + }} schema={{ type: prop.type, properties: prop.properties, @@ -587,9 +587,9 @@ const AppsTab = ({ /> ) : ( - (formRefs.current[key] = ref) - } + ref={(ref) => { + formRefs.current[key] = ref; + }} schema={{ type: prop.type, properties: prop.properties, diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 90249ab1c..b016865da 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -28,7 +28,6 @@ interface DynamicJsonFormProps { export interface DynamicJsonFormRef { validateJson: () => { isValid: boolean; error: string | null }; - hasJsonError: () => boolean; } const isTypeSupported = ( @@ -131,11 +130,7 @@ const DynamicJsonForm = forwardRef( // Use a ref to manage debouncing timeouts to avoid parsing JSON // on every keystroke which would be inefficient and error-prone - const timeoutRef = useRef>(); - - const hasJsonError = () => { - return !!jsonError; - }; + const timeoutRef = useRef>(undefined); const getPathKey = (path: string[]) => path.length === 0 ? "$root" : path.join("."); @@ -292,7 +287,6 @@ const DynamicJsonForm = forwardRef( useImperativeHandle(ref, () => ({ validateJson, - hasJsonError, })); const renderFormFields = ( diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationRequest.tsx index 4488a9620..ede282f94 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/client/src/components/ElicitationRequest.tsx @@ -3,12 +3,11 @@ import { Button } from "@/components/ui/button"; import DynamicJsonForm from "./DynamicJsonForm"; import JsonView from "./JsonView"; import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; -import { generateDefaultValue } from "@/utils/schemaUtils"; +import { generateDefaultValue, getAjvForSchema } from "@/utils/schemaUtils"; import { PendingElicitationRequest, ElicitationResponse, } from "./ElicitationTab"; -import Ajv from "ajv"; export type ElicitationRequestProps = { request: PendingElicitationRequest; @@ -79,7 +78,7 @@ const ElicitationRequest = ({ return; } - const ajv = new Ajv(); + const ajv = getAjvForSchema(request.request.requestedSchema); const validate = ajv.compile(request.request.requestedSchema); const isValid = validate(formData); diff --git a/client/src/components/HistoryAndNotifications.tsx b/client/src/components/HistoryAndNotifications.tsx index 1c6060712..d61502da4 100644 --- a/client/src/components/HistoryAndNotifications.tsx +++ b/client/src/components/HistoryAndNotifications.tsx @@ -2,6 +2,13 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import JsonView from "./JsonView"; import { Button } from "@/components/ui/button"; +import { + HorizontalHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@/components/ui/resizable"; +import { usePanelToggle } from "@/hooks/use-panel-toggle"; const HistoryAndNotifications = ({ requestHistory, @@ -20,6 +27,8 @@ const HistoryAndNotifications = ({ const [expandedNotifications, setExpandedNotifications] = useState<{ [key: number]: boolean; }>({}); + const { panelRef: historySubRef, toggle: toggleHistorySub } = + usePanelToggle(); const toggleRequestExpansion = (index: number) => { setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] })); @@ -29,9 +38,24 @@ const HistoryAndNotifications = ({ setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] })); }; + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "persistence-history-notifications", + }); + return ( -
-
+ +

History

-
+ + + + +

Server Notifications

-
+
+
); }; diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index e88aadb5a..4192edbe9 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -1,32 +1,34 @@ -import { Search } from "lucide-react"; +import { Search, X } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { useState, useMemo, useRef } from "react"; +import { useState, useMemo } from "react"; type ListPaneProps = { items: T[]; listItems: () => void; clearItems?: () => void; + selectedItem?: T | null; setSelectedItem: (item: T) => void; renderItem: (item: T) => React.ReactNode; title: string; buttonText: string; isButtonDisabled?: boolean; + searchRef?: React.RefObject; }; const ListPane = ({ items, listItems, clearItems, + selectedItem, setSelectedItem, renderItem, title, buttonText, isButtonDisabled, + searchRef, }: ListPaneProps) => { const [searchQuery, setSearchQuery] = useState(""); - const [isSearchExpanded, setIsSearchExpanded] = useState(false); - const searchInputRef = useRef(null); const filteredItems = useMemo(() => { if (!searchQuery.trim()) return items; @@ -42,80 +44,69 @@ const ListPane = ({ }); }, [items, searchQuery]); - const handleSearchClick = () => { - setIsSearchExpanded(true); - setTimeout(() => { - searchInputRef.current?.focus(); - }, 100); - }; - - const handleSearchBlur = () => { - if (!searchQuery.trim()) { - setIsSearchExpanded(false); - } - }; - return ( -
-
-
-

- {title} -

-
- {!isSearchExpanded ? ( - - ) : ( -
-
- - setSearchQuery(e.target.value)} - onBlur={handleSearchBlur} - className="pl-10 w-full transition-all duration-300 ease-in-out" - /> -
-
- )} -
-
+
+
+

{title}

-
- - {clearItems && ( + {clearItems && ( + + )} +
+ {items.length > 3 && ( +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10 w-full" + /> + {searchQuery && ( + + )} +
)} -
+
{filteredItems.map((item, index) => (
setSelectedItem(item)} > {renderItem(item)} diff --git a/client/src/components/PingTab.tsx b/client/src/components/PingTab.tsx index 65469016d..6a5025183 100644 --- a/client/src/components/PingTab.tsx +++ b/client/src/components/PingTab.tsx @@ -3,8 +3,8 @@ import { Button } from "@/components/ui/button"; const PingTab = ({ onPingClick }: { onPingClick: () => void }) => { return ( - -
+ +
- {promptContent && ( - + + + +
+
+
+ {selectedPrompt && ( + )} +

+ {selectedPrompt ? selectedPrompt.name : "Select a prompt"} +

- ) : ( - - - Select a prompt from the list to view and use it - - - )} +
+
+ {error ? ( + + + Error + + {error} + + + ) : selectedPrompt ? ( +
+ {selectedPrompt.description && ( +

+ {selectedPrompt.description} +

+ )} + {selectedPrompt.arguments?.map((arg) => ( +
+ + handleInputChange(arg.name, value)} + onInputChange={(value) => + handleInputChange(arg.name, value) + } + onFocus={() => handleFocus(arg.name)} + options={completions[arg.name] || []} + /> + + {arg.description && ( +

+ {arg.description} + {arg.required && ( + + (Required) + + )} +

+ )} +
+ ))} + + {promptContent && ( + + )} +
+ ) : ( + + + Select a prompt from the list to view and use it + + + )} +
-
-
+ +
); }; diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 36e5cec8f..1f0625eb5 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -13,6 +13,13 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; +import { + ResizableHandle, + HorizontalHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@/components/ui/resizable"; import { useEffect, useState } from "react"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; import JsonView from "./JsonView"; @@ -110,191 +117,231 @@ const ResourcesTab = ({ } }; + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "persistence-resources-tab", + }); + return ( - -
- { - clearResources(); - // Condition to check if selected resource is not resource template's resource - if (!selectedTemplate) { - setSelectedResource(null); - } - }} - setSelectedItem={(resource) => { - setSelectedResource(resource); - readResource(resource.uri); - setSelectedTemplate(null); - }} - renderItem={(resource) => ( -
- - {!(resource as WithIcons).icons && ( - + + + +
+ { + clearResources(); + // Condition to check if selected resource is not resource template's resource + if (!selectedTemplate) { + setSelectedResource(null); + } + }} + setSelectedItem={(resource) => { + setSelectedResource(resource); + readResource(resource.uri); + setSelectedTemplate(null); + }} + renderItem={(resource) => ( +
+ + {!(resource as WithIcons).icons && ( + + )} + + {resource.name} + + +
)} - - {resource.name} - - -
- )} - title="Resources" - buttonText={nextCursor ? "List More Resources" : "List Resources"} - isButtonDisabled={!nextCursor && resources.length > 0} - /> + title="Resources" + buttonText={nextCursor ? "List More Resources" : "List Resources"} + isButtonDisabled={!nextCursor && resources.length > 0} + /> +
+ - { - clearResourceTemplates(); - // Condition to check if selected resource is resource template's resource - if (selectedTemplate) { - setSelectedResource(null); - } - setSelectedTemplate(null); - }} - setSelectedItem={(template) => { - setSelectedTemplate(template); - setSelectedResource(null); - setTemplateValues({}); - }} - renderItem={(template) => ( -
- - {!(template as WithIcons).icons && ( - - )} - - {template.name} - - -
- )} - title="Resource Templates" - buttonText={ - nextTemplateCursor ? "List More Templates" : "List Templates" - } - isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} - /> + -
-
-
- {(selectedResource || selectedTemplate) && ( - - )} -

- {selectedResource - ? selectedResource.name - : selectedTemplate - ? selectedTemplate.name - : "Select a resource or template"} -

-
- {selectedResource && ( -
- {resourceSubscriptionsSupported && - !resourceSubscriptions.has(selectedResource.uri) && ( - - )} - {resourceSubscriptionsSupported && - resourceSubscriptions.has(selectedResource.uri) && ( - + +
+ { + clearResourceTemplates(); + // Condition to check if selected resource is resource template's resource + if (selectedTemplate) { + setSelectedResource(null); + } + setSelectedTemplate(null); + }} + setSelectedItem={(template) => { + setSelectedTemplate(template); + setSelectedResource(null); + setTemplateValues({}); + }} + renderItem={(template) => ( +
+ + {!(template as WithIcons).icons && ( + )} -
+ )} + title="Resource Templates" + buttonText={ + nextTemplateCursor ? "List More Templates" : "List Templates" + } + isButtonDisabled={ + !nextTemplateCursor && resourceTemplates.length > 0 + } + /> +
+
+ + + + +
+
+
+ {(selectedResource || selectedTemplate) && ( + + )} +

- - Refresh - + {selectedResource + ? selectedResource.name + : selectedTemplate + ? selectedTemplate.name + : "Select a resource or template"} +

- )} -
-
- {error ? ( - - - Error - - {error} - - - ) : selectedResource ? ( - - ) : selectedTemplate ? ( -
-

- {selectedTemplate.description} -

- {new UriTemplate( - selectedTemplate.uriTemplate, - ).variableNames?.map((key) => { - return ( -
- - - handleTemplateValueChange(key, value) + {selectedResource && ( +
+ {resourceSubscriptionsSupported && + !resourceSubscriptions.has(selectedResource.uri) && ( + + )} + {resourceSubscriptionsSupported && + resourceSubscriptions.has(selectedResource.uri) && ( +
- ); - })} - -
- ) : ( - - - Select a resource or template from the list to view its - contents - - - )} + > + Unsubscribe + + )} + +
+ )} +
+
+ {error ? ( + + + Error + + {error} + + + ) : selectedResource ? ( + + ) : selectedTemplate ? ( +
+

+ {selectedTemplate.description} +

+ {new UriTemplate( + selectedTemplate.uriTemplate, + ).variableNames?.map((key) => { + return ( +
+ + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
+ ); + })} + +
+ ) : ( + + + Select a resource or template from the list to view its + contents + + + )} +
-
-
+ + ); }; diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 13a4f24f4..5d16fbf2d 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -238,7 +238,7 @@ const Sidebar = ({
-

+

MCP Inspector v{version}

diff --git a/client/src/components/TasksTab.tsx b/client/src/components/TasksTab.tsx index 32ffa7d46..98c7d8b34 100644 --- a/client/src/components/TasksTab.tsx +++ b/client/src/components/TasksTab.tsx @@ -14,6 +14,12 @@ import { import ListPane from "./ListPane"; import { useState } from "react"; import JsonView from "./JsonView"; +import { + HorizontalHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@/components/ui/resizable"; import { cn } from "@/lib/utils"; const TaskStatusIcon = ({ status }: { status: Task["status"] }) => { @@ -67,161 +73,177 @@ const TasksTab = ({ } }; + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "persistence-tasks-tab", + }); + return ( - -
-
- 0} - renderItem={(task) => ( -
- -
- {task.taskId} - - {task.status} -{" "} - {new Date(task.lastUpdatedAt).toLocaleString()} - + + + +
+ 0} + renderItem={(task) => ( +
+ +
+ {task.taskId} + + {task.status} -{" "} + {new Date(task.lastUpdatedAt).toLocaleString()} + +
-
- )} - /> -
+ )} + /> +
+ -
- {error && ( - - - Error - {error} - - )} + - {displayedTask ? ( -
-
-
-

- Task Details -

-

- ID: {displayedTask.taskId} -

-
- {(displayedTask.status === "working" || - displayedTask.status === "input_required") && ( - - )} -
+ +
+ {error && ( + + + Error + {error} + + )} -
-
-

- Status -

-
- - - {displayedTask.status.replace("_", " ")} - + {displayedTask ? ( +
+
+
+

+ Task Details +

+

+ ID: {displayedTask.taskId} +

+ {(displayedTask.status === "working" || + displayedTask.status === "input_required") && ( + + )}
-
-

- Last Updated -

-

- {new Date(displayedTask.lastUpdatedAt).toLocaleString()} -

-
-
-

- Created At -

-

- {new Date(displayedTask.createdAt).toLocaleString()} -

-
-
-

- TTL -

-

- {displayedTask.ttl === null - ? "Infinite" - : `${displayedTask.ttl}ms`} -

-
-
- {displayedTask.statusMessage && ( -
-

- Status Message -

-

- {displayedTask.statusMessage} -

+
+
+

+ Status +

+
+ + + {displayedTask.status.replace("_", " ")} + +
+
+
+

+ Last Updated +

+

+ {new Date(displayedTask.lastUpdatedAt).toLocaleString()} +

+
+
+

+ Created At +

+

+ {new Date(displayedTask.createdAt).toLocaleString()} +

+
+
+

+ TTL +

+

+ {displayedTask.ttl === null + ? "Infinite" + : `${displayedTask.ttl}ms`} +

+
- )} -
-

Full Task Object

-
- + {displayedTask.statusMessage && ( +
+

+ Status Message +

+

+ {displayedTask.statusMessage} +

+
+ )} + +
+

Full Task Object

+
+ +
-
- ) : ( -
-
- -

No Task Selected

-

Select a task from the list to view its details.

- + ) : ( +
+
+ +

No Task Selected

+

Select a task from the list to view its details.

+ +
-
- )} -
-
+ )} +
+ + ); }; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index febea1d8f..24313c156 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -35,11 +35,20 @@ import { AlertCircle, Copy, CheckCheck, + Plus, + Type, } from "lucide-react"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; +import { + HorizontalHandle, + ResizablePanel, + ResizablePanelGroup, + useDefaultLayout, +} from "@/components/ui/resizable"; +import { usePanelToggle } from "@/hooks/use-panel-toggle"; import { useToast } from "@/lib/hooks/useToast"; import useCopy from "@/lib/hooks/useCopy"; import IconDisplay, { WithIcons } from "./IconDisplay"; @@ -53,6 +62,8 @@ import { isReservedMetaKey, } from "@/utils/metaUtils"; +import { InspectorConfig } from "@/lib/configurationTypes"; + /** * Extended Tool type that includes optional fields used by the inspector. */ @@ -177,6 +188,7 @@ const ToolsTab = ({ error, resourceContent, onReadResource, + config, serverSupportsTaskRequests, }: { tools: Tool[]; @@ -196,6 +208,7 @@ const ToolsTab = ({ error: string | null; resourceContent: Record; onReadResource?: (uri: string) => void; + config: InspectorConfig; serverSupportsTaskRequests: boolean; }) => { const [params, setParams] = useState>({}); @@ -203,40 +216,136 @@ const ToolsTab = ({ const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetadataExpanded, setIsMetadataExpanded] = useState(false); + const [isSticky, setIsSticky] = useState(false); + const sentinelRef = useRef(null); + const scrollContainerRef = useRef(null); const [metadataEntries, setMetadataEntries] = useState< { id: string; key: string; value: string }[] >([]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsSticky(!entry.isIntersecting); + }, + { threshold: 0 }, + ); + + observer.observe(sentinel); + return () => observer.unobserve(sentinel); + }, [selectedTool]); const [hasValidationErrors, setHasValidationErrors] = useState(false); + const [multilineFields, setMultilineFields] = useState>( + new Set(), + ); const formRefs = useRef>({}); + const searchRef = useRef(null); + const { panelRef: toolsRef, toggle: toggleTools } = usePanelToggle(); const { toast } = useToast(); const { copied, setCopied } = useCopy(); + useEffect(() => { + if (tools.length > 0) { + searchRef.current?.focus(); + } + }, [tools.length]); + + const toggleMultiline = useCallback((key: string) => { + setMultilineFields((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const enableHistory = config.MCP_ENABLE_BROWSER_HISTORY?.value !== false; + // Function to check if any form has validation errors - const checkValidationErrors = (validateChildren: boolean = false) => { + const checkValidationErrors = () => { const errors = Object.values(formRefs.current).some( - (ref) => - ref && - (validateChildren ? !ref.validateJson().isValid : ref.hasJsonError()), + (ref) => ref && !ref.validateJson().isValid, ); setHasValidationErrors(errors); return errors; }; + const handleSubmit = async (e: React.FormEvent) => { + const enableHistory = config.MCP_ENABLE_BROWSER_HISTORY?.value !== false; + + // Note: If history is enabled, we do NOT preventDefault here to allow the + // browser to see a successful submission to the hidden iframe. + if (!enableHistory) { + e.preventDefault(); + } + + // Validate JSON inputs before calling tool + if (checkValidationErrors()) { + e.preventDefault(); // Always stop submission if there are validation errors + return; + } + + try { + const scrollPos = scrollContainerRef.current?.scrollTop; + setIsToolRunning(true); + const metadata = metadataEntries.reduce>( + (acc, { key, value }) => { + const trimmedKey = key.trim(); + if ( + trimmedKey !== "" && + hasValidMetaPrefix(trimmedKey) && + !isReservedMetaKey(trimmedKey) && + hasValidMetaName(trimmedKey) + ) { + acc[trimmedKey] = value; + } + return acc; + }, + {}, + ); + await callTool( + selectedTool!.name, + params, + Object.keys(metadata).length ? metadata : undefined, + runAsTask, + ); + + // Restore scroll position after results might have expanded the container + if (scrollPos !== undefined && scrollContainerRef.current) { + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollPos; + } + }); + } + } finally { + setIsToolRunning(false); + } + }; + useEffect(() => { + if (!selectedTool) return; + const params = Object.entries( - selectedTool?.inputSchema.properties ?? [], + selectedTool.inputSchema.properties ?? [], ).map(([key, value]) => { // First resolve any $ref references const resolvedValue = resolveRef( value as JsonSchemaType, - selectedTool?.inputSchema as JsonSchemaType, + selectedTool.inputSchema as JsonSchemaType, ); return [ key, generateDefaultValue( resolvedValue, key, - selectedTool?.inputSchema as JsonSchemaType, + selectedTool.inputSchema as JsonSchemaType, ), ]; }); @@ -268,500 +377,655 @@ const ToolsTab = ({ return trimmedKey !== "" && !hasValidMetaName(trimmedKey); }); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "persistence-tools-tab", + }); + const taskSupport = serverSupportsTaskRequests ? getTaskSupport(selectedTool) : "forbidden"; return ( - -
- { - clearTools(); - setSelectedTool(null); - setRunAsTask(false); - }} - setSelectedItem={setSelectedTool} - renderItem={(tool) => ( -
-
- -
-
- {tool.title || tool.name} - - {tool.description} - -
- -
- )} - title="Tools" - buttonText={nextCursor ? "List More Tools" : "List Tools"} - isButtonDisabled={!nextCursor && tools.length > 0} - /> - -
-
-
- {selectedTool && ( - + + + +
+ { + clearTools(); + setSelectedTool(null); + setRunAsTask(false); + }} + selectedItem={selectedTool} + setSelectedItem={setSelectedTool} + renderItem={(tool) => ( +
+
+ +
+
+ {tool.title || tool.name} + + {tool.description} + +
+ +
)} -

- {selectedTool - ? selectedTool.title || selectedTool.name - : "Select a tool"} -

-
+ title="Tools" + buttonText={nextCursor ? "List More Tools" : "List Tools"} + isButtonDisabled={!nextCursor && tools.length > 0} + searchRef={searchRef} + />
-
- {selectedTool ? ( -
- {error && ( - - - Error - - {error} - - + + + +
+
+
+ {selectedTool && ( + )} -

- {selectedTool.description} -

- - {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 ( -
-
- - {prop.nullable ? ( -
- - setParams({ - ...params, - [key]: checked - ? null - : prop.type === "array" - ? undefined - : prop.default !== null - ? prop.default - : prop.type === "boolean" - ? false - : prop.type === "string" - ? "" - : prop.type === "number" || - prop.type === "integer" - ? undefined - : undefined, - }) - } - /> - -
- ) : null} -
- -
- {prop.type === "boolean" ? ( -
- - setParams({ - ...params, - [key]: checked, - }) - } - /> - -
- ) : prop.type === "string" && prop.enum ? ( - - ) : prop.type === "string" ? ( -