diff --git a/.dockerignore b/.dockerignore index a3e960e3f..39d62ea77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,10 +7,9 @@ node_modules npm-debug.log # Build artifacts -client/dist -client/build -server/dist -server/build +web/dist +cli/build +tui/build # Environment variables .env @@ -32,4 +31,4 @@ coverage # Docker Dockerfile -.dockerignore \ No newline at end of file +.dockerignore diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 106d98f13..ed2b2c1b5 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -28,8 +28,17 @@ jobs: cd .. npm ci --ignore-scripts + - name: Build core package + working-directory: . + run: npm run build-core + + - name: Build test package + working-directory: . + run: npm run build-test + - name: Build CLI - run: npm run build + working-directory: . + run: npm run build-cli - name: Run tests run: npm test diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml new file mode 100644 index 000000000..ae91a1034 --- /dev/null +++ b/.github/workflows/core_tests.yml @@ -0,0 +1,33 @@ +name: Core Tests + +on: + push: + paths: + - "core/**" + pull_request: + paths: + - "core/**" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build test package + run: npm run build-test + + - name: Build core + run: npm run build-core + + - name: Run core tests + run: npm run test-core diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 4013a9052..c9849f992 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -56,9 +56,9 @@ jobs: with: name: playwright-report path: | - client/playwright-report/ - client/test-results/ - client/results.json + web/playwright-report/ + web/test-results/ + web/results.json retention-days: 2 - name: Publish Playwright Test Summary @@ -66,7 +66,7 @@ jobs: if: steps.playwright-tests.conclusion != 'skipped' with: create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - report-file: client/results.json + report-file: web/results.json comment-title: "šŸŽ­ Playwright E2E Test Results" job-summary: true icon-style: "emojis" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2cc4537ba..157407a10 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,9 +14,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Check formatting - run: npx prettier --check . - - uses: actions/setup-node@v6 with: node-version-file: package.json @@ -29,15 +26,14 @@ jobs: - name: Check version consistency run: npm run check-version - - name: Check linting - working-directory: ./client + - name: Lint run: npm run lint - - name: Run client tests - working-directory: ./client - run: npm test + - name: Build + run: npm run build - - run: npm run build + - name: Run tests + run: npm test publish: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 230d72d41..e1e4848ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,19 +3,29 @@ .idea node_modules/ *-workspace/ -server/build -client/dist -client/tsconfig.app.tsbuildinfo -client/tsconfig.node.tsbuildinfo +web/tsconfig.app.tsbuildinfo +web/tsconfig.node.tsbuildinfo +web/tsconfig.jest.tsbuildinfo +core/build cli/build +tui/build +test-servers/build test-output tool-test-output metadata-test-output # symlinked by `npm run link:sdk`: sdk -client/playwright-report/ -client/results.json -client/test-results/ -client/e2e/test-results/ -mcp.json +web/playwright-report/ +web/results.json +web/test-results/ +web/e2e/test-results/ +# Only ignore mcp.json at repo root (configs in configs/ are committed) +/mcp.json .claude/settings.local.json + +# Environment variables +.env +.env.local +.env.development +.env.test +.env.production diff --git a/.prettierignore b/.prettierignore index 167a7cb48..6f8827fb8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,9 @@ packages -server/build +core/build +web/dist +cli/build +tui/build +test-servers/build CODE_OF_CONDUCT.md SECURITY.md mcp.json diff --git a/AGENTS.md b/AGENTS.md index 27d28f72d..968a5a838 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,10 @@ ## Build Commands - Build all: `npm run build` -- Build client: `npm run build-client` -- Build server: `npm run build-server` +- Build web: `npm run build-web` - Development mode: `npm run dev` (use `npm run dev:windows` on Windows) - Format code: `npm run prettier-fix` -- Client lint: `cd client && npm run lint` +- Web lint: `cd web && npm run lint` ## Code Style Guidelines @@ -23,13 +22,15 @@ - kebab-case for file names - Use async/await for asynchronous operations - Implement proper error handling with try/catch blocks -- Use Tailwind CSS for styling in the client +- Use Tailwind CSS for styling in the web app - Keep components small and focused on a single responsibility ## Project Organization The project is organized as a monorepo with workspaces: -- `client/`: React frontend with Vite, TypeScript and Tailwind -- `server/`: Express backend with TypeScript +- `web/`: Web application (Vite, TypeScript, Tailwind) +- `core/`: Core shared code used by web, CLI, and TUI - `cli/`: Command-line interface for testing and invoking MCP server methods directly +- `tui/`: Terminal user interface +- `test-servers/`: Composable MCP test servers, fixtures, and harness diff --git a/Dockerfile b/Dockerfile index d66091d16..2b4adfbc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,10 @@ WORKDIR /app # Copy package files for installation COPY package*.json ./ COPY .npmrc ./ -COPY client/package*.json ./client/ -COPY server/package*.json ./server/ +COPY web/package*.json ./web/ +COPY core/package*.json ./core/ COPY cli/package*.json ./cli/ +COPY tui/package*.json ./tui/ # Install dependencies RUN npm ci --ignore-scripts @@ -28,25 +29,22 @@ WORKDIR /app # Copy package files for production COPY package*.json ./ COPY .npmrc ./ -COPY client/package*.json ./client/ -COPY server/package*.json ./server/ +COPY web/package*.json ./web/ +COPY core/package*.json ./core/ COPY cli/package*.json ./cli/ +COPY tui/package*.json ./tui/ # Install only production dependencies RUN npm ci --omit=dev --ignore-scripts # Copy built files from builder stage -COPY --from=builder /app/client/dist ./client/dist -COPY --from=builder /app/client/bin ./client/bin -COPY --from=builder /app/server/build ./server/build +COPY --from=builder /app/web/dist ./web/dist +COPY --from=builder /app/web/bin ./web/bin COPY --from=builder /app/cli/build ./cli/build -# Set default port values as environment variables -ENV CLIENT_PORT=6274 -ENV SERVER_PORT=6277 +# Set default port +ENV PORT=6274 +EXPOSE ${PORT} -# Document which ports the application uses internally -EXPOSE ${CLIENT_PORT} ${SERVER_PORT} - -# Use ENTRYPOINT with CMD for arguments -ENTRYPOINT ["npm", "start"] +# Run web app +CMD ["node", "web/bin/start.js"] diff --git a/README.md b/README.md index 36e9f3dee..f68af0d92 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) an CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js ``` +**Environment variables:** The client UI port is set with `CLIENT_PORT`. The sandbox (MCP apps) port prefers `MCP_SANDBOX_PORT`; `SERVER_PORT` is accepted for backward compatibility. If neither is set, a dynamic port is used. + For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). ### Servers File Export @@ -182,7 +184,7 @@ The MCP Inspector proxy server requires authentication by default. When starting šŸ”‘ Session token: 3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 šŸ”— Open inspector with token pre-filled: - http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 + http://localhost:6274/?MCP_INSPECTOR_API_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 ``` This token must be included as a Bearer token in the Authorization header for all requests to the server. The inspector will automatically open your browser with the token pre-filled in the URL. @@ -213,10 +215,10 @@ Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE V --- -You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: +You can also set the token via the `MCP_INSPECTOR_API_TOKEN` environment variable when starting the server (`MCP_PROXY_AUTH_TOKEN` is accepted for backward compatibility): ```bash -MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start +MCP_INSPECTOR_API_TOKEN=$(openssl rand -hex 32) npm start ``` #### Local-only Binding diff --git a/cli/__tests__/cli.test.ts b/cli/__tests__/cli.test.ts index b263f618c..e88aff094 100644 --- a/cli/__tests__/cli.test.ts +++ b/cli/__tests__/cli.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, afterAll, expect } from "vitest"; +import { describe, it, expect } from "vitest"; import { runCli } from "./helpers/cli-runner.js"; import { expectCliSuccess, @@ -12,12 +12,12 @@ import { createInvalidConfig, deleteConfigFile, } from "./helpers/fixtures.js"; -import { getTestMcpServerCommand } from "./helpers/test-server-stdio.js"; -import { createTestServerHttp } from "./helpers/test-server-http.js"; import { + getTestMcpServerCommand, + createTestServerHttp, createEchoTool, createTestServerInfo, -} from "./helpers/test-fixtures.js"; +} from "@modelcontextprotocol/inspector-test-server"; describe("CLI Tests", () => { describe("Basic CLI Mode", () => { @@ -37,10 +37,10 @@ describe("CLI Tests", () => { expect(Array.isArray(json.tools)).toBe(true); // Validate expected tools from test-mcp-server - const toolNames = json.tools.map((tool: any) => tool.name); + const toolNames = json.tools.map((tool: { name: string }) => tool.name); expect(toolNames).toContain("echo"); - expect(toolNames).toContain("get-sum"); - expect(toolNames).toContain("get-annotated-message"); + expect(toolNames).toContain("get_sum"); + expect(toolNames).toContain("get_annotated_message"); }); it("should fail with nonexistent method", async () => { @@ -59,6 +59,17 @@ describe("CLI Tests", () => { expectCliFailure(result); }); + + // Temporary: remove .skip to verify that expectCliSuccess shows CLI stdout/stderr when the CLI fails + it.skip("TEMP: expect success when CLI fails (validates error output in assertion)", async () => { + const result = await runCli([ + NO_SERVER_SENTINEL, + "--cli", + "--method", + "tools/list", + ]); + expectCliSuccess(result); // will fail; failure message should include result.stdout and result.stderr + }); }); describe("Environment Variables", () => { @@ -304,7 +315,7 @@ describe("CLI Tests", () => { "--method", "prompts/get", "--prompt-name", - "simple-prompt", + "simple_prompt", ]); expectCliSuccess(result); @@ -329,7 +340,7 @@ describe("CLI Tests", () => { "--method", "prompts/get", "--prompt-name", - "args-prompt", + "args_prompt", "--prompt-args", "city=New York", "state=NY", @@ -370,11 +381,9 @@ describe("CLI Tests", () => { }); try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "logging/setLevel", @@ -541,7 +550,6 @@ describe("CLI Tests", () => { "test-sse": { type: "sse", url: "http://localhost:3000/sse", - note: "Test SSE server", }, }, }); @@ -568,7 +576,6 @@ describe("CLI Tests", () => { "test-http": { type: "streamable-http", url: "http://localhost:3001/mcp", - note: "Test HTTP server", }, }, }); @@ -741,11 +748,9 @@ describe("CLI Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -768,11 +773,9 @@ describe("CLI Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--transport", "http", "--cli", @@ -797,11 +800,9 @@ describe("CLI Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--transport", "http", "--cli", @@ -826,11 +827,9 @@ describe("CLI Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--transport", "sse", "--cli", diff --git a/cli/__tests__/headers.test.ts b/cli/__tests__/headers.test.ts index 6adf1effe..36d416d5c 100644 --- a/cli/__tests__/headers.test.ts +++ b/cli/__tests__/headers.test.ts @@ -5,11 +5,11 @@ import { expectOutputContains, expectCliSuccess, } from "./helpers/assertions.js"; -import { createTestServerHttp } from "./helpers/test-server-http.js"; import { + createTestServerHttp, createEchoTool, createTestServerInfo, -} from "./helpers/test-fixtures.js"; +} from "@modelcontextprotocol/inspector-test-server"; describe("Header Parsing and Validation", () => { describe("Valid Headers", () => { @@ -20,11 +20,9 @@ describe("Header Parsing and Validation", () => { }); try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -60,11 +58,9 @@ describe("Header Parsing and Validation", () => { }); try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -95,11 +91,9 @@ describe("Header Parsing and Validation", () => { }); try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -129,11 +123,9 @@ describe("Header Parsing and Validation", () => { }); try { - const port = await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", diff --git a/cli/__tests__/helpers/cli-runner.ts b/cli/__tests__/helpers/cli-runner.ts index 073aa9ae4..318d63b24 100644 --- a/cli/__tests__/helpers/cli-runner.ts +++ b/cli/__tests__/helpers/cli-runner.ts @@ -54,11 +54,11 @@ export async function runCli( // On Unix, kill the process group process.kill(-child.pid!, "SIGTERM"); } - } catch (e) { + } catch { // Process might already be dead, try direct kill try { child.kill("SIGKILL"); - } catch (e2) { + } catch { // Process is definitely dead } } diff --git a/cli/__tests__/helpers/fixtures.ts b/cli/__tests__/helpers/fixtures.ts index c7f10fc7a..bfae186ac 100644 --- a/cli/__tests__/helpers/fixtures.ts +++ b/cli/__tests__/helpers/fixtures.ts @@ -2,7 +2,8 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; -import { getTestMcpServerCommand } from "./test-server-stdio.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js"; /** * Sentinel value for tests that don't need a real server @@ -54,7 +55,7 @@ function createTempDir(prefix: string = "mcp-inspector-test-"): string { function cleanupTempDir(dir: string) { try { fs.rmSync(dir, { recursive: true, force: true }); - } catch (err) { + } catch { // Ignore cleanup errors } } @@ -63,7 +64,7 @@ function cleanupTempDir(dir: string) { * Create a test config file */ export function createTestConfig(config: { - mcpServers: Record; + mcpServers: Record; }): string { const tempDir = createTempDir("mcp-inspector-config-"); const configPath = path.join(tempDir, "config.json"); diff --git a/cli/__tests__/helpers/test-fixtures.ts b/cli/__tests__/helpers/test-fixtures.ts deleted file mode 100644 index d92d79ae0..000000000 --- a/cli/__tests__/helpers/test-fixtures.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Shared types and test fixtures for composable MCP test servers - */ - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import * as z from "zod/v4"; -import { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js"; - -type ToolInputSchema = ZodRawShapeCompat; - -export interface ToolDefinition { - name: string; - description: string; - inputSchema?: ToolInputSchema; - handler: (params: Record) => Promise; -} - -export interface ResourceDefinition { - uri: string; - name: string; - description?: string; - mimeType?: string; - text?: string; -} - -type PromptArgsSchema = ZodRawShapeCompat; - -export interface PromptDefinition { - name: string; - description?: string; - argsSchema?: PromptArgsSchema; -} - -// This allows us to compose tests servers using the metadata and features we want in a given scenario -export interface ServerConfig { - serverInfo: Implementation; // Server metadata (name, version, etc.) - required - tools?: ToolDefinition[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised) - resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised) - prompts?: PromptDefinition[]; // Prompts to register (optional, empty array means no prompts, but prompts capability is still advertised) - logging?: boolean; // Whether to advertise logging capability (default: false) -} - -/** - * Create an "echo" tool that echoes back the input message - */ -export function createEchoTool(): ToolDefinition { - return { - name: "echo", - description: "Echo back the input message", - inputSchema: { - message: z.string().describe("Message to echo back"), - }, - handler: async (params: Record) => { - return { message: `Echo: ${params.message as string}` }; - }, - }; -} - -/** - * Create an "add" tool that adds two numbers together - */ -export function createAddTool(): ToolDefinition { - return { - name: "add", - description: "Add two numbers together", - inputSchema: { - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - }, - handler: async (params: Record) => { - const a = params.a as number; - const b = params.b as number; - return { result: a + b }; - }, - }; -} - -/** - * Create a "get-sum" tool that returns the sum of two numbers (alias for add) - */ -export function createGetSumTool(): ToolDefinition { - return { - name: "get-sum", - description: "Get the sum of two numbers", - inputSchema: { - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - }, - handler: async (params: Record) => { - const a = params.a as number; - const b = params.b as number; - return { result: a + b }; - }, - }; -} - -/** - * Create a "get-annotated-message" tool that returns a message with optional image - */ -export function createGetAnnotatedMessageTool(): ToolDefinition { - return { - name: "get-annotated-message", - description: "Get an annotated message", - inputSchema: { - messageType: z - .enum(["success", "error", "warning", "info"]) - .describe("Type of message"), - includeImage: z - .boolean() - .optional() - .describe("Whether to include an image"), - }, - handler: async (params: Record) => { - const messageType = params.messageType as string; - const includeImage = params.includeImage as boolean | undefined; - const message = `This is a ${messageType} message`; - const content: Array< - | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string } - > = [ - { - type: "text", - text: message, - }, - ]; - - if (includeImage) { - content.push({ - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG - mimeType: "image/png", - }); - } - - return { content }; - }, - }; -} - -/** - * Create a "simple-prompt" prompt definition - */ -export function createSimplePrompt(): PromptDefinition { - return { - name: "simple-prompt", - description: "A simple prompt for testing", - }; -} - -/** - * Create an "args-prompt" prompt that accepts arguments - */ -export function createArgsPrompt(): PromptDefinition { - return { - name: "args-prompt", - description: "A prompt that accepts arguments for testing", - argsSchema: { - city: z.string().describe("City name"), - state: z.string().describe("State name"), - }, - }; -} - -/** - * Create an "architecture" resource definition - */ -export function createArchitectureResource(): ResourceDefinition { - return { - name: "architecture", - uri: "demo://resource/static/document/architecture.md", - description: "Architecture documentation", - mimeType: "text/markdown", - text: `# Architecture Documentation - -This is a test resource for the MCP test server. - -## Overview - -This resource is used for testing resource reading functionality in the CLI. - -## Sections - -- Introduction -- Design -- Implementation -- Testing - -## Notes - -This is a static resource provided by the test MCP server. -`, - }; -} - -/** - * Create a "test-cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server) - */ -export function createTestCwdResource(): ResourceDefinition { - return { - name: "test-cwd", - uri: "test://cwd", - description: "Current working directory of the test server", - mimeType: "text/plain", - text: process.cwd(), - }; -} - -/** - * Create a "test-env" resource that exposes environment variables (generally useful when testing with the stdio test server) - */ -export function createTestEnvResource(): ResourceDefinition { - return { - name: "test-env", - uri: "test://env", - description: "Environment variables available to the test server", - mimeType: "application/json", - text: JSON.stringify(process.env, null, 2), - }; -} - -/** - * Create a "test-argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server) - */ -export function createTestArgvResource(): ResourceDefinition { - return { - name: "test-argv", - uri: "test://argv", - description: "Command-line arguments the test server was started with", - mimeType: "application/json", - text: JSON.stringify(process.argv, null, 2), - }; -} - -/** - * Create minimal server info for test servers - */ -export function createTestServerInfo( - name: string = "test-server", - version: string = "1.0.0", -): Implementation { - return { - name, - version, - }; -} - -/** - * Get default server config with common test tools, prompts, and resources - */ -export function getDefaultServerConfig(): ServerConfig { - return { - serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"), - tools: [ - createEchoTool(), - createGetSumTool(), - createGetAnnotatedMessageTool(), - ], - prompts: [createSimplePrompt(), createArgsPrompt()], - resources: [ - createArchitectureResource(), - createTestCwdResource(), - createTestEnvResource(), - createTestArgvResource(), - ], - }; -} diff --git a/cli/__tests__/helpers/test-server-http.ts b/cli/__tests__/helpers/test-server-http.ts deleted file mode 100644 index d5eadc3ff..000000000 --- a/cli/__tests__/helpers/test-server-http.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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 type { Request, Response } from "express"; -import express from "express"; -import { createServer as createHttpServer, Server as HttpServer } from "http"; -import { createServer as createNetServer } from "net"; -import { randomUUID } from "crypto"; -import * as z from "zod/v4"; -import type { ServerConfig } from "./test-fixtures.js"; - -export interface RecordedRequest { - method: string; - params?: any; - headers?: Record; - metadata?: Record; - response: any; - 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, () => { - 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); - } else { - reject(err); - } - }); - }); -} - -/** - * Extract headers from Express request - */ -function extractHeaders(req: Request): Record { - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") { - headers[key] = value; - } else if (Array.isArray(value) && value.length > 0) { - headers[key] = value[value.length - 1]; - } - } - 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 config: ServerConfig; - private recordedRequests: RecordedRequest[] = []; - private httpServer?: HttpServer; - private transport?: StreamableHTTPServerTransport | SSEServerTransport; - private url?: string; - private currentRequestHeaders?: Record; - private currentLogLevel: string | null = null; - - constructor(config: ServerConfig) { - this.config = config; - const capabilities: { - tools?: {}; - resources?: {}; - prompts?: {}; - 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.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) }], - }; - }, - ); - } - } - - // 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 || "", - }, - ], - }; - }, - ); - } - } - - // 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)}` : ""}`, - }, - }, - ], - }; - }, - ); - } - } - } - - private setupLoggingHandler() { - // Intercept logging/setLevel requests to track the level - this.mcpServer.server.setRequestHandler( - SetLevelRequestSchema, - async (request) => { - this.currentLogLevel = request.params.level; - // Return empty result as per MCP spec - return {}; - }, - ); - } - - /** - * Start the server with the specified transport. - * When requestedPort is omitted, uses port 0 so the OS assigns a unique port (avoids EADDRINUSE when tests run in parallel). - */ - async start( - transport: "http" | "sse", - requestedPort?: number, - ): Promise { - const port = - requestedPort !== undefined ? await findAvailablePort(requestedPort) : 0; - - if (transport === "http") { - const actualPort = await this.startHttp(port); - this.url = `http://localhost:${actualPort}`; - return actualPort; - } else { - const actualPort = await this.startSse(port); - this.url = `http://localhost:${actualPort}`; - return actualPort; - } - } - - 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 - 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); - } - - // 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, - }); - throw error; - } - }; - - // 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, () => { - const assignedPort = (this.httpServer!.address() as { port: number }) - ?.port; - resolve(assignedPort ?? port); - }); - this.httpServer!.on("error", reject); - }); - } - - 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; - } - }; - - await this.mcpServer.connect(sseTransport); - await sseTransport.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, () => { - const assignedPort = (this.httpServer!.address() as { port: number }) - ?.port; - resolve(assignedPort ?? port); - }); - this.httpServer!.on("error", reject); - }); - } - - /** - * Stop the server - */ - async stop(): Promise { - await this.mcpServer.close(); - - if (this.transport) { - await this.transport.close(); - this.transport = undefined; - } - - if (this.httpServer) { - return new Promise((resolve) => { - // Force close all connections - this.httpServer!.closeAllConnections?.(); - this.httpServer!.close(() => { - this.httpServer = undefined; - 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"); - } - return this.url; - } - - /** - * Get the most recent log level that was set - */ - getCurrentLogLevel(): string | null { - return this.currentLogLevel; - } -} - -/** - * 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 deleted file mode 100644 index 7fe6a1c47..000000000 --- a/cli/__tests__/helpers/test-server-stdio.ts +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node - -/** - * Test MCP server for stdio transport testing - * Can be used programmatically or run as a standalone executable - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import * as z from "zod/v4"; -import path from "path"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import type { - ServerConfig, - ToolDefinition, - PromptDefinition, - ResourceDefinition, -} from "./test-fixtures.js"; -import { getDefaultServerConfig } from "./test-fixtures.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export class TestServerStdio { - private mcpServer: McpServer; - private config: ServerConfig; - private transport?: StdioServerTransport; - - constructor(config: ServerConfig) { - this.config = config; - const capabilities: { - tools?: {}; - resources?: {}; - prompts?: {}; - 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.setupHandlers(); - } - - 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); - // If handler returns content array directly (like get-annotated-message), use it - if (result && Array.isArray(result.content)) { - return { content: result.content }; - } - // If handler returns message (like echo), format it - if (result && typeof result.message === "string") { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - }; - } - // Otherwise, stringify the result - 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 () => { - // For dynamic resources, get fresh text - let text = resource.text; - if (resource.name === "test-cwd") { - text = process.cwd(); - } else if (resource.name === "test-env") { - text = JSON.stringify(process.env, null, 2); - } else if (resource.name === "test-argv") { - text = JSON.stringify(process.argv, null, 2); - } - - return { - contents: [ - { - uri: resource.uri, - mimeType: resource.mimeType || "text/plain", - text: 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) => { - if (prompt.name === "args-prompt" && args) { - const city = (args as any).city as string; - const state = (args as any).state as string; - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `This is a prompt with arguments: city=${city}, state=${state}`, - }, - }, - ], - }; - } else { - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: "This is a simple prompt for testing purposes.", - }, - }, - ], - }; - } - }, - ); - } - } - } - - /** - * Start the server with stdio transport - */ - async start(): Promise { - this.transport = new StdioServerTransport(); - await this.mcpServer.connect(this.transport); - } - - /** - * Stop the server - */ - async stop(): Promise { - await this.mcpServer.close(); - if (this.transport) { - await this.transport.close(); - this.transport = undefined; - } - } -} - -/** - * Create a stdio MCP test server - */ -export function createTestServerStdio(config: ServerConfig): TestServerStdio { - return new TestServerStdio(config); -} - -/** - * Get the path to the test MCP server script - */ -export function getTestMcpServerPath(): string { - return path.resolve(__dirname, "test-server-stdio.ts"); -} - -/** - * Get the command and args to run the test MCP server - */ -export function getTestMcpServerCommand(): { command: string; args: string[] } { - return { - command: "tsx", - args: [getTestMcpServerPath()], - }; -} - -// If run as a standalone script, start with default config -// Check if this file is being executed directly (not imported) -const isMainModule = - import.meta.url.endsWith(process.argv[1]) || - process.argv[1]?.endsWith("test-server-stdio.ts") || - process.argv[1]?.endsWith("test-server-stdio.js"); - -if (isMainModule) { - const server = new TestServerStdio(getDefaultServerConfig()); - server - .start() - .then(() => { - // Server is now running and listening on stdio - // Keep the process alive - }) - .catch((error) => { - console.error("Failed to start test MCP server:", error); - process.exit(1); - }); -} diff --git a/cli/__tests__/metadata.test.ts b/cli/__tests__/metadata.test.ts index 93d5f8ca6..ac2876f6c 100644 --- a/cli/__tests__/metadata.test.ts +++ b/cli/__tests__/metadata.test.ts @@ -5,12 +5,12 @@ import { expectCliFailure, expectValidJson, } from "./helpers/assertions.js"; -import { createTestServerHttp } from "./helpers/test-server-http.js"; import { + createTestServerHttp, createEchoTool, createAddTool, createTestServerInfo, -} from "./helpers/test-fixtures.js"; +} from "@modelcontextprotocol/inspector-test-server"; import { NO_SERVER_SENTINEL } from "./helpers/fixtures.js"; describe("Metadata Tests", () => { @@ -22,11 +22,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -65,11 +63,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "resources/list", @@ -104,16 +100,15 @@ describe("Metadata Tests", () => { { name: "test-prompt", description: "A test prompt", + promptString: "test prompt", }, ], }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "prompts/list", @@ -154,11 +149,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "resources/read", @@ -193,16 +186,15 @@ describe("Metadata Tests", () => { { name: "test-prompt", description: "A test prompt", + promptString: "test prompt", }, ], }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "prompts/get", @@ -239,11 +231,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -280,11 +270,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -324,11 +312,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -371,11 +357,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -412,11 +396,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -453,11 +435,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -496,11 +476,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -533,11 +511,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -612,11 +588,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -661,11 +635,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "resources/list", @@ -698,16 +670,15 @@ describe("Metadata Tests", () => { { name: "test-prompt", description: "A test prompt", + promptString: "test prompt", }, ], }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "prompts/get", @@ -744,11 +715,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -791,11 +760,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/list", @@ -830,11 +797,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -884,11 +849,9 @@ describe("Metadata Tests", () => { }); try { - await server.start("http"); - const serverUrl = `${server.getUrl()}/mcp`; - + await server.start(); const result = await runCli([ - serverUrl, + server.url, "--cli", "--method", "tools/call", @@ -930,4 +893,42 @@ describe("Metadata Tests", () => { } }); }); + + describe("SSE Transport Tests", () => { + it("should work with tools/list using SSE transport", async () => { + const server = createTestServerHttp({ + serverType: "sse", + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--metadata", + "client=test-client", + "--transport", + "sse", + ]); + + expectCliSuccess(result); + const json = expectValidJson(result); + expect(json).toHaveProperty("tools"); + + // Validate metadata was sent + const recordedRequests = server.getRecordedRequests(); + const toolsListRequest = recordedRequests.find( + (r) => r.method === "tools/list", + ); + expect(toolsListRequest).toBeDefined(); + expect(toolsListRequest?.metadata).toEqual({ client: "test-client" }); + } finally { + await server.stop(); + } + }); + }); }); diff --git a/cli/__tests__/tools.test.ts b/cli/__tests__/tools.test.ts index e83b5ea0d..3a7ea6a17 100644 --- a/cli/__tests__/tools.test.ts +++ b/cli/__tests__/tools.test.ts @@ -6,7 +6,7 @@ import { expectValidJson, expectJsonError, } from "./helpers/assertions.js"; -import { getTestMcpServerCommand } from "./helpers/test-server-stdio.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; describe("Tool Tests", () => { describe("Tool Discovery", () => { @@ -29,10 +29,10 @@ describe("Tool Tests", () => { expect(json.tools[0]).toHaveProperty("name"); expect(json.tools[0]).toHaveProperty("description"); // Validate expected tools from test-mcp-server - const toolNames = json.tools.map((tool: any) => tool.name); + const toolNames = json.tools.map((tool: { name: string }) => tool.name); expect(toolNames).toContain("echo"); - expect(toolNames).toContain("get-sum"); - expect(toolNames).toContain("get-annotated-message"); + expect(toolNames).toContain("get_sum"); + expect(toolNames).toContain("get_annotated_message"); }); }); @@ -69,7 +69,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-sum", + "get_sum", "--tool-arg", "a=42", "b=58", @@ -95,7 +95,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-sum", + "get_sum", "--tool-arg", "a=19.99", "b=20.01", @@ -121,7 +121,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-annotated-message", + "get_annotated_message", "--tool-arg", "messageType=success", "includeImage=true", @@ -133,7 +133,9 @@ describe("Tool Tests", () => { expect(Array.isArray(json.content)).toBe(true); // Should have both text and image content expect(json.content.length).toBeGreaterThan(1); - const hasImage = json.content.some((item: any) => item.type === "image"); + const hasImage = json.content.some( + (item: { type?: string }) => item.type === "image", + ); expect(hasImage).toBe(true); }); @@ -146,7 +148,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-annotated-message", + "get_annotated_message", "--tool-arg", "messageType=error", "includeImage=false", @@ -157,7 +159,9 @@ describe("Tool Tests", () => { expect(json).toHaveProperty("content"); expect(Array.isArray(json.content)).toBe(true); // Should only have text content, no image - const hasImage = json.content.some((item: any) => item.type === "image"); + const hasImage = json.content.some( + (item: { type?: string }) => item.type === "image", + ); expect(hasImage).toBe(false); // test-mcp-server returns "This is a {messageType} message" expect(json.content[0].text.toLowerCase()).toContain("error"); @@ -195,7 +199,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-sum", + "get_sum", "--tool-arg", "a=42.5", "b=57.5", @@ -374,7 +378,7 @@ describe("Tool Tests", () => { "message=test", ]); - // CLI returns exit code 0 but includes isError: true in JSON + // CLI returns exit code 0 but includes isError: true in JSON (server returns error) expectJsonError(result); }); @@ -421,7 +425,7 @@ describe("Tool Tests", () => { "--method", "prompts/get", "--prompt-name", - "args-prompt", + "args_prompt", "--prompt-args", "city=New York", "state=NY", @@ -441,7 +445,7 @@ describe("Tool Tests", () => { }); it("should handle prompt with simple arguments", async () => { - // Note: simple-prompt doesn't accept arguments, but the CLI should still + // Note: simple_prompt doesn't accept arguments, but the CLI should still // accept the command and the server should ignore the arguments const { command, args } = getTestMcpServerCommand(); const result = await runCli([ @@ -451,7 +455,7 @@ describe("Tool Tests", () => { "--method", "prompts/get", "--prompt-name", - "simple-prompt", + "simple_prompt", "--prompt-args", "name=test", "count=5", @@ -464,7 +468,7 @@ describe("Tool Tests", () => { expect(json.messages.length).toBeGreaterThan(0); expect(json.messages[0]).toHaveProperty("content"); expect(json.messages[0].content).toHaveProperty("type", "text"); - // test-mcp-server's simple-prompt returns standard message (ignoring args) + // test-mcp-server's simple_prompt returns standard message (ignoring args) expect(json.messages[0].content.text).toBe( "This is a simple prompt for testing purposes.", ); @@ -503,7 +507,7 @@ describe("Tool Tests", () => { "--method", "tools/call", "--tool-name", - "get-sum", + "get_sum", "--tool-arg", "a=10", "b=20", diff --git a/cli/package.json b/cli/package.json index 94a59d848..53d435273 100644 --- a/cli/package.json +++ b/cli/package.json @@ -2,8 +2,8 @@ "name": "@modelcontextprotocol/inspector-cli", "version": "0.20.0", "description": "CLI for the Model Context Protocol inspector", - "license": "SEE LICENSE IN LICENSE", - "author": "Model Context Protocol a Series of LF Projects, LLC.", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "main": "build/cli.js", @@ -17,6 +17,7 @@ "scripts": { "build": "tsc", "postbuild": "node scripts/make-executable.js", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "test": "vitest run", "test:watch": "vitest", "test:cli": "vitest run cli.test.ts", @@ -25,14 +26,16 @@ "test:cli-metadata": "vitest run metadata.test.ts" }, "devDependencies": { + "@modelcontextprotocol/inspector-test-server": "*", "@types/express": "^5.0.0", "tsx": "^4.7.0", "vitest": "^4.0.17" }, "dependencies": { + "@modelcontextprotocol/inspector-core": "*", "@modelcontextprotocol/sdk": "^1.25.2", "commander": "^13.1.0", - "express": "^5.2.1", + "express": "^5.1.0", "spawn-rx": "^5.1.2" } } diff --git a/cli/src/cli.ts b/cli/src/cli.ts index f4187e02d..a7212c406 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -9,24 +9,33 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); +// This represents the parsed arguments produced by parseArgs() +// type Args = { command: string; args: string[]; envArgs: Record; cli: boolean; + dev?: boolean; transport?: "stdio" | "sse" | "streamable-http"; serverUrl?: string; headers?: Record; + cwd?: string; }; +// This is only to provide typed access to the parsed program options +// This could just be defined locally in parseArgs() since that's the only place it is used +// type CliOptions = { e?: Record; config?: string; server?: string; cli?: boolean; + dev?: boolean; transport?: string; serverUrl?: string; header?: Record; + cwd?: string; }; type ServerConfig = @@ -35,10 +44,12 @@ type ServerConfig = command: string; args?: string[]; env?: Record; + cwd?: string; } | { type: "sse" | "streamable-http"; url: string; + headers?: Record; note?: string; }; @@ -58,16 +69,12 @@ function handleError(error: unknown): never { process.exit(1); } -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms, true)); -} - -async function runWebClient(args: Args): Promise { - // Path to the client entry point - const inspectorClientPath = resolve( +async function runWeb(args: Args): Promise { + // Path to the web entry point + const inspectorWebPath = resolve( __dirname, "../../", - "client", + "web", "bin", "start.js", ); @@ -82,6 +89,10 @@ async function runWebClient(args: Args): Promise { // Build arguments to pass to start.js const startArgs: string[] = []; + if (args.dev) { + startArgs.push("--dev"); + } + // Pass environment variables for (const [key, value] of Object.entries(args.envArgs)) { startArgs.push("-e", `${key}=${value}`); @@ -97,13 +108,23 @@ async function runWebClient(args: Args): Promise { startArgs.push("--server-url", args.serverUrl); } + // Pass headers if specified (for SSE/streamable-http) + if (args.headers && Object.keys(args.headers).length > 0) { + startArgs.push("--headers", JSON.stringify(args.headers)); + } + + // Pass cwd if specified (for stdio transport) + if (args.cwd) { + startArgs.push("--cwd", path.resolve(args.cwd)); + } + // Pass command and args (using -- to separate them) if (args.command) { startArgs.push("--", args.command, ...args.args); } try { - await spawnPromise("node", [inspectorClientPath, ...startArgs], { + await spawnPromise("node", [inspectorWebPath, ...startArgs], { signal: abort.signal, echoOutput: true, // pipe the stdout through here, prevents issues with buffering and @@ -151,6 +172,11 @@ async function runCli(args: Args): Promise { } } + // Add cwd if specified (for stdio transport) + if (args.cwd) { + cliArgs.push("--cwd", path.resolve(args.cwd)); + } + await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, @@ -167,6 +193,36 @@ async function runCli(args: Args): Promise { } } +async function runTui(tuiArgs: string[]): Promise { + const projectRoot = resolve(__dirname, "../.."); + const tuiPath = resolve(projectRoot, "tui", "build", "tui.js"); + + const abort = new AbortController(); + + let cancelled = false; + + process.on("SIGINT", () => { + cancelled = true; + abort.abort(); + }); + + try { + // Remove --tui flag and pass everything else directly to TUI + const filteredArgs = tuiArgs.filter((arg) => arg !== "--tui"); + + await spawnPromise("node", [tuiPath, ...filteredArgs], { + env: process.env, + signal: abort.signal, + echoOutput: true, + stdio: "inherit", + }); + } catch (e) { + if (!cancelled || process.env.DEBUG) { + throw e; + } + } +} + function loadConfigFile(configPath: string, serverName: string): ServerConfig { try { const resolvedConfigPath = path.isAbsolute(configPath) @@ -190,7 +246,13 @@ function loadConfigFile(configPath: string, serverName: string): ServerConfig { } const serverConfig = parsedConfig.mcpServers[serverName]; - + if (serverConfig?.type === undefined) { + // Normalize missing type to "stdio" (backwards compatibility) + return { ...serverConfig, type: "stdio" }; + } else if (serverConfig.type === "http") { + // Normalize "http" to "streamable-http" (some clients, like Claude Code, use http instead of streamable-http) + return { ...serverConfig, type: "streamable-http" }; + } return serverConfig; } catch (err: unknown) { if (err instanceof SyntaxError) { @@ -267,8 +329,12 @@ function parseArgs(): Args { .option("--config ", "config file path") .option("--server ", "server name from config file") .option("--cli", "enable CLI mode") + .option("--web", "launch web app (default)") + .option("--dev", "run web in dev mode (Vite)") + .option("--tui", "enable TUI mode") .option("--transport ", "transport type (stdio, sse, http)") .option("--server-url ", "server URL for SSE/HTTP transport") + .option("--cwd ", "working directory for stdio server process") .option( "--header ", 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', @@ -285,9 +351,14 @@ function parseArgs(): Args { // Add back any arguments that came after -- const finalArgs = [...remainingArgs, ...postArgs]; - // Validate config and server options + // If server specified but no config, default to mcp.json in cwd if it exists if (!options.config && options.server) { - throw new Error("--server requires --config to be specified"); + const defaultConfigPath = path.join(process.cwd(), "mcp.json"); + if (fs.existsSync(defaultConfigPath)) { + options.config = "mcp.json"; + } else { + throw new Error("--server requires --config to be specified"); + } } // If config is provided without server, try to auto-select @@ -319,37 +390,35 @@ function parseArgs(): Args { // etc.) if (options.config && options.server) { const config = loadConfigFile(options.config, options.server); - if (config.type === "stdio") { + const cwd = options.cwd ?? config.cwd ?? path.resolve(process.cwd()); return { command: config.command, args: [...(config.args || []), ...finalArgs], envArgs: { ...(config.env || {}), ...(options.e || {}) }, cli: options.cli || false, + dev: options.dev || false, transport: "stdio", headers: options.header, + cwd: path.resolve(cwd), }; } else if (config.type === "sse" || config.type === "streamable-http") { + const headers = { + ...(config.headers || {}), + ...(options.header || {}), + }; return { command: config.url, args: finalArgs, envArgs: options.e || {}, cli: options.cli || false, + dev: options.dev || false, transport: config.type, serverUrl: config.url, - headers: options.header, - }; - } else { - // Backwards compatibility: if no type field, assume stdio - return { - command: (config as any).command || "", - args: [...((config as any).args || []), ...finalArgs], - envArgs: { ...((config as any).env || {}), ...(options.e || {}) }, - cli: options.cli || false, - transport: "stdio", - headers: options.header, + headers: Object.keys(headers).length > 0 ? headers : undefined, }; } + throw new Error(`Invalid server config: ${JSON.stringify(config)}`); } // Otherwise use command line arguments @@ -362,14 +431,20 @@ function parseArgs(): Args { transport = "streamable-http"; } + const cwd = options.cwd + ? path.resolve(options.cwd) + : path.resolve(process.cwd()); + return { command, args, envArgs: options.e || {}, cli: options.cli || false, + dev: options.dev || false, transport: transport as "stdio" | "sse" | "streamable-http" | undefined, serverUrl: options.serverUrl, headers: options.header, + cwd, }; } @@ -379,12 +454,21 @@ async function main(): Promise { }); try { + // For now we just pass the raw args to TUI (we'll integrate config later) + // The main issue is that Inspector only supports a single server and the TUI supports a set + // + // Check for --tui in raw argv - if present, bypass all parsing + if (process.argv.includes("--tui")) { + await runTui(process.argv.slice(2)); + return; + } + const args = parseArgs(); if (args.cli) { await runCli(args); } else { - await runWebClient(args); + await runWeb(args); } } catch (error) { handleError(error); diff --git a/cli/src/client/connection.ts b/cli/src/client/connection.ts deleted file mode 100644 index dcbe8e518..000000000 --- a/cli/src/client/connection.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { McpResponse } from "./types.js"; - -export const validLogLevels = [ - "trace", - "debug", - "info", - "warn", - "error", -] as const; - -export type LogLevel = (typeof validLogLevels)[number]; - -export async function connect( - client: Client, - transport: Transport, -): Promise { - try { - await client.connect(transport); - - if (client.getServerCapabilities()?.logging) { - // default logging level is undefined in the spec, but the user of the - // inspector most likely wants debug. - await client.setLoggingLevel("debug"); - } - } catch (error) { - throw new Error( - `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -export async function disconnect(transport: Transport): Promise { - try { - await transport.close(); - } catch (error) { - throw new Error( - `Failed to disconnect from MCP server: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Set logging level -export async function setLoggingLevel( - client: Client, - level: LogLevel, -): Promise { - try { - const response = await client.setLoggingLevel(level as any); - return response; - } catch (error) { - throw new Error( - `Failed to set logging level: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/index.ts b/cli/src/client/index.ts deleted file mode 100644 index 095d716b2..000000000 --- a/cli/src/client/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Re-export everything from the client modules -export * from "./connection.js"; -export * from "./prompts.js"; -export * from "./resources.js"; -export * from "./tools.js"; -export * from "./types.js"; diff --git a/cli/src/client/prompts.ts b/cli/src/client/prompts.ts deleted file mode 100644 index e7a1cf2f2..000000000 --- a/cli/src/client/prompts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { McpResponse } from "./types.js"; - -// JSON value type matching the client utils -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -// List available prompts -export async function listPrompts( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listPrompts(params); - return response; - } catch (error) { - throw new Error( - `Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Get a prompt -export async function getPrompt( - client: Client, - name: string, - args?: Record, - metadata?: Record, -): Promise { - try { - // Convert all arguments to strings for prompt arguments - const stringArgs: Record = {}; - if (args) { - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } else if (value === null || value === undefined) { - stringArgs[key] = String(value); - } else { - stringArgs[key] = JSON.stringify(value); - } - } - } - - const params: any = { - name, - arguments: stringArgs, - }; - - if (metadata && Object.keys(metadata).length > 0) { - params._meta = metadata; - } - - const response = await client.getPrompt(params); - - return response; - } catch (error) { - throw new Error( - `Failed to get prompt: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/resources.ts b/cli/src/client/resources.ts deleted file mode 100644 index 3e44820ca..000000000 --- a/cli/src/client/resources.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { McpResponse } from "./types.js"; - -// List available resources -export async function listResources( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listResources(params); - return response; - } catch (error) { - throw new Error( - `Failed to list resources: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// Read a resource -export async function readResource( - client: Client, - uri: string, - metadata?: Record, -): Promise { - try { - const params: any = { uri }; - if (metadata && Object.keys(metadata).length > 0) { - params._meta = metadata; - } - const response = await client.readResource(params); - return response; - } catch (error) { - throw new Error( - `Failed to read resource ${uri}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -// List resource templates -export async function listResourceTemplates( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listResourceTemplates(params); - return response; - } catch (error) { - throw new Error( - `Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/tools.ts b/cli/src/client/tools.ts deleted file mode 100644 index 516814115..000000000 --- a/cli/src/client/tools.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { McpResponse } from "./types.js"; - -// JSON value type matching the client utils -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -type JsonSchemaType = { - type: "string" | "number" | "integer" | "boolean" | "array" | "object"; - description?: string; - properties?: Record; - items?: JsonSchemaType; -}; - -export async function listTools( - client: Client, - metadata?: Record, -): Promise { - try { - const params = - metadata && Object.keys(metadata).length > 0 ? { _meta: metadata } : {}; - const response = await client.listTools(params); - return response; - } catch (error) { - throw new Error( - `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -function convertParameterValue( - value: string, - schema: JsonSchemaType, -): JsonValue { - if (!value) { - return value; - } - - if (schema.type === "number" || schema.type === "integer") { - return Number(value); - } - - if (schema.type === "boolean") { - return value.toLowerCase() === "true"; - } - - if (schema.type === "object" || schema.type === "array") { - try { - return JSON.parse(value) as JsonValue; - } catch (error) { - return value; - } - } - - return value; -} - -function convertParameters( - tool: Tool, - params: Record, -): Record { - const result: Record = {}; - const properties = tool.inputSchema.properties || {}; - - for (const [key, value] of Object.entries(params)) { - const paramSchema = properties[key] as JsonSchemaType | undefined; - - if (paramSchema) { - result[key] = convertParameterValue(value, paramSchema); - } else { - // If no schema is found for this parameter, keep it as string - result[key] = value; - } - } - - return result; -} - -export async function callTool( - client: Client, - name: string, - args: Record, - generalMetadata?: Record, - toolSpecificMetadata?: Record, -): Promise { - try { - const toolsResponse = await listTools(client, generalMetadata); - const tools = toolsResponse.tools as Tool[]; - const tool = tools.find((t) => t.name === name); - - let convertedArgs: Record = args; - - if (tool) { - // Convert parameters based on the tool's schema, but only for string values - // since we now accept pre-parsed values from the CLI - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } - } - - // Merge general metadata with tool-specific metadata - // Tool-specific metadata takes precedence over general metadata - let mergedMetadata: Record | undefined; - if (generalMetadata || toolSpecificMetadata) { - mergedMetadata = { - ...(generalMetadata || {}), - ...(toolSpecificMetadata || {}), - }; - } - - const response = await client.callTool({ - name: name, - arguments: convertedArgs, - _meta: - mergedMetadata && Object.keys(mergedMetadata).length > 0 - ? mergedMetadata - : undefined, - }); - return response; - } catch (error) { - throw new Error( - `Failed to call tool ${name}: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/src/client/types.ts b/cli/src/client/types.ts deleted file mode 100644 index bbbe1bf4f..000000000 --- a/cli/src/client/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type McpResponse = Record; diff --git a/cli/src/index.ts b/cli/src/index.ts index 45a71a052..7200e1263 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,36 +1,35 @@ #!/usr/bin/env node import * as fs from "fs"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Command } from "commander"; -import { - callTool, - connect, - disconnect, - getPrompt, - listPrompts, - listResources, - listResourceTemplates, - listTools, - LogLevel, - McpResponse, - readResource, - setLoggingLevel, - validLogLevels, -} from "./client/index.js"; +// CLI helper functions moved to InspectorClient methods +type McpResponse = Record; import { handleError } from "./error-handler.js"; -import { createTransport, TransportOptions } from "./transport.js"; import { awaitableLog } from "./utils/awaitable-log.js"; +import type { + MCPServerConfig, + StdioServerConfig, + SseServerConfig, + StreamableHttpServerConfig, +} from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { + ManagedToolsState, + ManagedResourcesState, + ManagedResourceTemplatesState, + ManagedPromptsState, +} from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; +import { createTransportNode } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import type { JsonValue } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { + LoggingLevelSchema, + type LoggingLevel, +} from "@modelcontextprotocol/sdk/types.js"; +import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"; -// JSON value type for CLI arguments -type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; +export const validLogLevels: LoggingLevel[] = Object.values( + LoggingLevelSchema.enum, +); type Args = { target: string[]; @@ -38,100 +37,199 @@ type Args = { promptName?: string; promptArgs?: Record; uri?: string; - logLevel?: LogLevel; + logLevel?: LoggingLevel; toolName?: string; toolArg?: Record; toolMeta?: Record; transport?: "sse" | "stdio" | "http"; headers?: Record; metadata?: Record; + cwd?: string; }; -function createTransportOptions( - target: string[], - transport?: "sse" | "stdio" | "http", - headers?: Record, -): TransportOptions { - if (target.length === 0) { +/** + * Converts CLI Args to MCPServerConfig format + * This will be used to create an InspectorClient + */ +function argsToMcpServerConfig(args: Args): MCPServerConfig { + if (args.target.length === 0) { throw new Error( "Target is required. Specify a URL or a command to execute.", ); } - const [command, ...commandArgs] = target; + const [firstTarget, ...targetArgs] = args.target; - if (!command) { - throw new Error("Command is required."); + if (!firstTarget) { + throw new Error("Target is required."); } - const isUrl = command.startsWith("http://") || command.startsWith("https://"); + const isUrl = + firstTarget.startsWith("http://") || firstTarget.startsWith("https://"); - if (isUrl && commandArgs.length > 0) { + // Validation: URLs cannot have additional arguments + if (isUrl && targetArgs.length > 0) { throw new Error("Arguments cannot be passed to a URL-based MCP server."); } - let transportType: "sse" | "stdio" | "http"; - if (transport) { - if (!isUrl && transport !== "stdio") { + // Validation: Transport/URL combinations + if (args.transport) { + if (!isUrl && args.transport !== "stdio") { throw new Error("Only stdio transport can be used with local commands."); } - if (isUrl && transport === "stdio") { + if (isUrl && args.transport === "stdio") { throw new Error("stdio transport cannot be used with URLs."); } - transportType = transport; - } else if (isUrl) { - const url = new URL(command); - if (url.pathname.endsWith("/mcp")) { - transportType = "http"; - } else if (url.pathname.endsWith("/sse")) { - transportType = "sse"; + } + + // Handle URL-based transports (SSE or streamable-http) + if (isUrl) { + const url = new URL(firstTarget); + + // Determine transport type + let transportType: "sse" | "streamable-http"; + if (args.transport) { + // Convert CLI's "http" to "streamable-http" + if (args.transport === "http") { + transportType = "streamable-http"; + } else if (args.transport === "sse") { + transportType = "sse"; + } else { + // Should not happen due to validation above, but default to SSE + transportType = "sse"; + } + } else { + // Auto-detect from URL path + if (url.pathname.endsWith("/mcp")) { + transportType = "streamable-http"; + } else if (url.pathname.endsWith("/sse")) { + transportType = "sse"; + } else { + // Default to SSE if path doesn't match known patterns + transportType = "sse"; + } + } + + // Create SSE or streamable-http config + if (transportType === "sse") { + const config: SseServerConfig = { + type: "sse", + url: firstTarget, + }; + if (args.headers) { + config.headers = args.headers; + } + return config; } else { - transportType = "sse"; + const config: StreamableHttpServerConfig = { + type: "streamable-http", + url: firstTarget, + }; + if (args.headers) { + config.headers = args.headers; + } + return config; } - } else { - transportType = "stdio"; } - return { - transportType, - command: isUrl ? undefined : command, - args: isUrl ? undefined : commandArgs, - url: isUrl ? command : undefined, - headers, + // Handle stdio transport (command-based) + const config: StdioServerConfig = { + type: "stdio", + command: firstTarget, + }; + + if (targetArgs.length > 0) { + config.args = targetArgs; + } + + const processEnv: Record = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + processEnv[key] = value; + } + } + + const defaultEnv = getDefaultEnvironment(); + + const env: Record = { + ...defaultEnv, + ...processEnv, }; + + config.env = env; + + if (args.cwd?.trim()) { + config.cwd = args.cwd.trim(); + } + + return config; } async function callMethod(args: Args): Promise { // Read package.json to get name and version for client identity const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector - let packageJson: { name: string; version: string }; - let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { + const packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { with: { type: "json" }, }); - packageJson = packageJsonData.default; - - const transportOptions = createTransportOptions( - args.target, - args.transport, - args.headers, - ); - const transport = createTransport(transportOptions); + const packageJson = packageJsonData.default as { + name: string; + version: string; + }; const [, name = packageJson.name] = packageJson.name.split("/"); const version = packageJson.version; const clientIdentity = { name, version }; - const client = new Client(clientIdentity); + const inspectorClient = new InspectorClient(argsToMcpServerConfig(args), { + environment: { + transport: createTransportNode, + }, + clientIdentity, + initialLoggingLevel: "debug", // Set debug logging level for CLI + progress: false, // CLI doesn't use progress; avoids SDK injecting progressToken into _meta + sample: false, // CLI doesn't need sampling capability + elicit: false, // CLI doesn't need elicitation capability + }); + + let managedToolsState: ManagedToolsState | null = null; + let managedResourcesState: ManagedResourcesState | null = null; + let managedResourceTemplatesState: ManagedResourceTemplatesState | null = + null; + let managedPromptsState: ManagedPromptsState | null = null; try { - await connect(client, transport); + await inspectorClient.connect(); let result: McpResponse; - // Tools methods + // Tools methods: use ManagedToolsState for both tools/list and tools/call + if (args.method === "tools/list" || args.method === "tools/call") { + managedToolsState = new ManagedToolsState(inspectorClient); + managedToolsState.setMetadata(args.metadata); + await managedToolsState.refresh(); + } + + // Resources / resource templates / prompts: use managed state when listing + if (args.method === "resources/list") { + managedResourcesState = new ManagedResourcesState(inspectorClient); + managedResourcesState.setMetadata(args.metadata); + await managedResourcesState.refresh(); + } else if (args.method === "resources/templates/list") { + managedResourceTemplatesState = new ManagedResourceTemplatesState( + inspectorClient, + ); + managedResourceTemplatesState.setMetadata(args.metadata); + await managedResourceTemplatesState.refresh(); + } else if (args.method === "prompts/list") { + managedPromptsState = new ManagedPromptsState(inspectorClient); + managedPromptsState.setMetadata(args.metadata); + await managedPromptsState.refresh(); + } + if (args.method === "tools/list") { - result = await listTools(client, args.metadata); + result = { tools: managedToolsState!.getTools() }; } else if (args.method === "tools/call") { if (!args.toolName) { throw new Error( @@ -139,17 +237,48 @@ async function callMethod(args: Args): Promise { ); } - result = await callTool( - client, - args.toolName, - args.toolArg || {}, - args.metadata, - args.toolMeta, - ); + const tool = managedToolsState! + .getTools() + .find((t) => t.name === args.toolName); + if (!tool) { + // Same result shape as server error (so CLI output and tests unchanged) + result = { + content: [ + { + type: "text" as const, + text: `Tool '${args.toolName}' not found.`, + }, + ], + isError: true, + }; + } else { + const invocation = await inspectorClient.callTool( + tool, + args.toolArg || {}, + args.metadata, + args.toolMeta, + ); + // Extract the result from the invocation object for CLI compatibility + if (invocation.result !== null) { + result = invocation.result; + } else { + result = { + content: [ + { + type: "text" as const, + text: invocation.error || "Tool call failed", + }, + ], + isError: true, + }; + } + } } // Resources methods else if (args.method === "resources/list") { - result = await listResources(client, args.metadata); + result = { + resources: managedResourcesState!.getResources(), + }; } else if (args.method === "resources/read") { if (!args.uri) { throw new Error( @@ -157,13 +286,21 @@ async function callMethod(args: Args): Promise { ); } - result = await readResource(client, args.uri, args.metadata); + const invocation = await inspectorClient.readResource( + args.uri, + args.metadata, + ); + // Extract the result from the invocation object for CLI compatibility + result = invocation.result; } else if (args.method === "resources/templates/list") { - result = await listResourceTemplates(client, args.metadata); + result = { + resourceTemplates: + managedResourceTemplatesState!.getResourceTemplates(), + }; } // Prompts methods else if (args.method === "prompts/list") { - result = await listPrompts(client, args.metadata); + result = { prompts: managedPromptsState!.getPrompts() }; } else if (args.method === "prompts/get") { if (!args.promptName) { throw new Error( @@ -171,12 +308,13 @@ async function callMethod(args: Args): Promise { ); } - result = await getPrompt( - client, + const invocation = await inspectorClient.getPrompt( args.promptName, args.promptArgs || {}, args.metadata, ); + // Extract the result from the invocation object for CLI compatibility + result = invocation.result; } // Logging methods else if (args.method === "logging/setLevel") { @@ -186,7 +324,8 @@ async function callMethod(args: Args): Promise { ); } - result = await setLoggingLevel(client, args.logLevel); + await inspectorClient.setLoggingLevel(args.logLevel); + result = {}; } else { throw new Error( `Unsupported method: ${args.method}. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel`, @@ -195,11 +334,11 @@ async function callMethod(args: Args): Promise { await awaitableLog(JSON.stringify(result, null, 2)); } finally { - try { - await disconnect(transport); - } catch (disconnectError) { - throw disconnectError; - } + managedToolsState?.destroy(); + managedResourcesState?.destroy(); + managedResourceTemplatesState?.destroy(); + managedPromptsState?.destroy(); + await inspectorClient.disconnect(); } } @@ -308,18 +447,19 @@ function parseArgs(): Args { "--log-level ", "Logging level (for logging/setLevel method)", (value: string) => { - if (!validLogLevels.includes(value as any)) { + if (!validLogLevels.includes(value as LoggingLevel)) { throw new Error( `Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`, ); } - return value as LogLevel; + return value as LoggingLevel; }, ) // // Transport options // + .option("--cwd ", "Working directory for stdio server process") .option( "--transport ", "Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio", @@ -367,7 +507,7 @@ function parseArgs(): Args { toolMetadata?: Record; }; - let remainingArgs = program.args; + const remainingArgs = program.args; // Add back any arguments that came after -- const finalArgs = [...remainingArgs, ...postArgs]; @@ -381,6 +521,7 @@ function parseArgs(): Args { return { target: finalArgs, ...options, + cwd: options.cwd, headers: options.header, // commander.js uses 'header' field, map to 'headers' metadata: options.metadata ? Object.fromEntries( diff --git a/cli/src/transport.ts b/cli/src/transport.ts deleted file mode 100644 index 84af393b9..000000000 --- a/cli/src/transport.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { - getDefaultEnvironment, - StdioClientTransport, -} from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { findActualExecutable } from "spawn-rx"; - -export type TransportOptions = { - transportType: "sse" | "stdio" | "http"; - command?: string; - args?: string[]; - url?: string; - headers?: Record; -}; - -function createStdioTransport(options: TransportOptions): Transport { - let args: string[] = []; - - if (options.args !== undefined) { - args = options.args; - } - - const processEnv: Record = {}; - - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - processEnv[key] = value; - } - } - - const defaultEnv = getDefaultEnvironment(); - - const env: Record = { - ...defaultEnv, - ...processEnv, - }; - - const { cmd: actualCommand, args: actualArgs } = findActualExecutable( - options.command ?? "", - args, - ); - - return new StdioClientTransport({ - command: actualCommand, - args: actualArgs, - env, - stderr: "pipe", - }); -} - -export function createTransport(options: TransportOptions): Transport { - const { transportType } = options; - - try { - if (transportType === "stdio") { - return createStdioTransport(options); - } - - // If not STDIO, then it must be either SSE or HTTP. - if (!options.url) { - throw new Error("URL must be provided for SSE or HTTP transport types."); - } - const url = new URL(options.url); - - if (transportType === "sse") { - const transportOptions = options.headers - ? { - requestInit: { - headers: options.headers, - }, - } - : undefined; - return new SSEClientTransport(url, transportOptions); - } - - if (transportType === "http") { - const transportOptions = options.headers - ? { - requestInit: { - headers: options.headers, - }, - } - : undefined; - return new StreamableHTTPClientTransport(url, transportOptions); - } - - throw new Error(`Unsupported transport type: ${transportType}`); - } catch (error) { - throw new Error( - `Failed to create transport: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} diff --git a/cli/tsconfig.json b/cli/tsconfig.json index effa34f2b..6baeb1a50 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -13,5 +13,6 @@ "noUncheckedIndexedAccess": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"] + "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"], + "references": [{ "path": "../core" }] } diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 9984fb11a..af55eda86 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["**/__tests__/**/*.test.ts"], + include: ["__tests__/**/*.test.ts"], testTimeout: 15000, // 15 seconds - CLI tests spawn subprocesses that need time }, }); diff --git a/client/bin/client.js b/client/bin/client.js deleted file mode 100755 index 2a7419e66..000000000 --- a/client/bin/client.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node - -import open from "open"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import handler from "serve-handler"; -import http from "http"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const distPath = join(__dirname, "../dist"); - -const server = http.createServer((request, response) => { - const handlerOptions = { - public: distPath, - rewrites: [{ source: "/**", destination: "/index.html" }], - headers: [ - { - // Ensure index.html is never cached - source: "index.html", - headers: [ - { - key: "Cache-Control", - value: "no-cache, no-store, max-age=0", - }, - ], - }, - { - // Allow long-term caching for hashed assets - source: "assets/**", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=31536000, immutable", - }, - ], - }, - ], - }; - - return handler(request, response, handlerOptions); -}); - -const port = parseInt(process.env.CLIENT_PORT || "6274", 10); -const host = process.env.HOST || "localhost"; -server.on("listening", () => { - const url = process.env.INSPECTOR_URL || `http://${host}:${port}`; - console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log(`🌐 Opening browser...`); - open(url); - } -}); -server.on("error", (err) => { - if (err.message.includes(`EADDRINUSE`)) { - console.error( - `āŒ MCP Inspector PORT IS IN USE at http://${host}:${port} āŒ `, - ); - } else { - throw err; - } -}); -server.listen(port, host); diff --git a/client/bin/start.js b/client/bin/start.js deleted file mode 100755 index 7e7e013af..000000000 --- a/client/bin/start.js +++ /dev/null @@ -1,350 +0,0 @@ -#!/usr/bin/env node - -import open from "open"; -import { resolve, dirname } from "path"; -import { spawnPromise, spawn } from "spawn-rx"; -import { fileURLToPath } from "url"; -import { randomBytes } from "crypto"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms, true)); -} - -function getClientUrl(port, authDisabled, sessionToken, serverPort) { - const host = process.env.HOST || "localhost"; - const baseUrl = `http://${host}:${port}`; - - const params = new URLSearchParams(); - if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) { - params.set("MCP_PROXY_PORT", serverPort); - } - if (!authDisabled) { - params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); - } - return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; -} - -async function startDevServer(serverOptions) { - const { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - transport, - serverUrl, - } = serverOptions; - const serverCommand = "npx"; - const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; - const isWindows = process.platform === "win32"; - - const spawnOptions = { - cwd: resolve(__dirname, "../..", "server"), - env: { - ...process.env, - SERVER_PORT, - CLIENT_PORT, - MCP_PROXY_AUTH_TOKEN: sessionToken, - MCP_ENV_VARS: JSON.stringify(envVars), - ...(transport ? { MCP_TRANSPORT: transport } : {}), - ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}), - }, - signal: abort.signal, - echoOutput: true, - }; - - // 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) { - spawnOptions.stdio = ["ignore", "pipe", "pipe"]; - } - - const server = spawn(serverCommand, serverArgs, spawnOptions); - - // Give server time to start - const serverOk = await Promise.race([ - new Promise((resolve) => { - server.subscribe({ - complete: () => resolve(false), - error: () => resolve(false), - next: () => {}, // We're using echoOutput - }); - }), - delay(3000).then(() => true), - ]); - - return { server, serverOk }; -} - -async function startProdServer(serverOptions) { - const { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - } = serverOptions; - const inspectorServerPath = resolve( - __dirname, - "../..", - "server", - "build", - "index.js", - ); - - const server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(command ? [`--command=${command}`] : []), - ...(mcpServerArgs && mcpServerArgs.length > 0 - ? [`--args=${mcpServerArgs.join(" ")}`] - : []), - ...(transport ? [`--transport=${transport}`] : []), - ...(serverUrl ? [`--server-url=${serverUrl}`] : []), - ], - { - env: { - ...process.env, - SERVER_PORT, - CLIENT_PORT, - MCP_PROXY_AUTH_TOKEN: sessionToken, - MCP_ENV_VARS: JSON.stringify(envVars), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - - // Make sure server started before starting client - const serverOk = await Promise.race([server, delay(2 * 1000)]); - - return { server, serverOk }; -} - -async function startDevClient(clientOptions) { - const { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - } = clientOptions; - const clientCommand = "npx"; - const host = process.env.HOST || "localhost"; - const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; - const isWindows = process.platform === "win32"; - - const spawnOptions = { - cwd: resolve(__dirname, ".."), - env: { ...process.env, CLIENT_PORT }, - signal: abort.signal, - 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( - CLIENT_PORT, - authDisabled, - sessionToken, - SERVER_PORT, - ); - - // Give vite time to start before opening or logging the URL - setTimeout(() => { - console.log(`\nšŸš€ MCP Inspector is up and running at:\n ${url}\n`); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log("🌐 Opening browser..."); - open(url); - } - }, 3000); - - await new Promise((resolve) => { - client.subscribe({ - complete: resolve, - error: (err) => { - if (!cancelled || process.env.DEBUG) { - console.error("Client error:", err); - } - resolve(null); - }, - next: () => {}, // We're using echoOutput - }); - }); -} - -async function startProdClient(clientOptions) { - const { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - } = clientOptions; - const inspectorClientPath = resolve( - __dirname, - "../..", - "client", - "bin", - "client.js", - ); - - const url = getClientUrl( - CLIENT_PORT, - authDisabled, - sessionToken, - SERVER_PORT, - ); - - await spawnPromise("node", [inspectorClientPath], { - env: { - ...process.env, - CLIENT_PORT, - INSPECTOR_URL: url, - }, - signal: abort.signal, - echoOutput: true, - }); -} - -async function main() { - // Parse command line arguments - const args = process.argv.slice(2); - const envVars = {}; - const mcpServerArgs = []; - let command = null; - let parsingFlags = true; - let isDev = false; - let transport = null; - let serverUrl = null; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (parsingFlags && arg === "--") { - parsingFlags = false; - continue; - } - - if (parsingFlags && arg === "--dev") { - isDev = true; - continue; - } - - if (parsingFlags && arg === "--transport" && i + 1 < args.length) { - transport = args[++i]; - continue; - } - - if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { - serverUrl = args[++i]; - continue; - } - - if (parsingFlags && arg === "-e" && i + 1 < args.length) { - const envVar = args[++i]; - const equalsIndex = envVar.indexOf("="); - - if (equalsIndex !== -1) { - const key = envVar.substring(0, equalsIndex); - const value = envVar.substring(equalsIndex + 1); - envVars[key] = value; - } else { - envVars[envVar] = ""; - } - } else if (!command && !isDev) { - command = arg; - } else if (!isDev) { - mcpServerArgs.push(arg); - } - } - - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; - - console.log( - isDev - ? "Starting MCP inspector in development mode..." - : "Starting MCP inspector...", - ); - - // 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 abort = new AbortController(); - - let cancelled = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - let server, serverOk; - - try { - const serverOptions = { - SERVER_PORT, - CLIENT_PORT, - sessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - }; - - const result = isDev - ? await startDevServer(serverOptions) - : await startProdServer(serverOptions); - - server = result.server; - serverOk = result.serverOk; - } catch (error) {} - - if (serverOk) { - try { - const clientOptions = { - CLIENT_PORT, - SERVER_PORT, - authDisabled, - sessionToken, - abort, - cancelled, - }; - - await (isDev - ? startDevClient(clientOptions) - : startProdClient(clientOptions)); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } - } - - return 0; -} - -main() - .then((_) => process.exit(0)) - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/client/jest.config.cjs b/client/jest.config.cjs deleted file mode 100644 index 704852961..000000000 --- a/client/jest.config.cjs +++ /dev/null @@ -1,43 +0,0 @@ -module.exports = { - preset: "ts-jest", - testEnvironment: "jest-fixed-jsdom", - moduleNameMapper: { - "^@/(.*)$": "/src/$1", - "\\.css$": "/src/__mocks__/styleMock.js", - }, - transform: { - "^.+\\.tsx?$": [ - "ts-jest", - { - jsx: "react-jsx", - tsconfig: "tsconfig.jest.json", - }, - ], - "^.+\\.m?js$": [ - "ts-jest", - { - tsconfig: "tsconfig.jest.json", - }, - ], - }, - extensionsToTreatAsEsm: [".ts", ".tsx"], - transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol)/)"], - testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - // Exclude directories and files that don't need to be tested - testPathIgnorePatterns: [ - "/node_modules/", - "/dist/", - "/bin/", - "/e2e/", - "\\.config\\.(js|ts|cjs|mjs)$", - ], - // Exclude the same patterns from coverage reports - coveragePathIgnorePatterns: [ - "/node_modules/", - "/dist/", - "/bin/", - "/e2e/", - "\\.config\\.(js|ts|cjs|mjs)$", - ], - randomize: true, -}; 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.tsx b/client/src/App.tsx deleted file mode 100644 index 5a69495b6..000000000 --- a/client/src/App.tsx +++ /dev/null @@ -1,1649 +0,0 @@ -import { - ClientRequest, - CompatibilityCallToolResult, - CompatibilityCallToolResultSchema, - CreateMessageResult, - EmptyResultSchema, - GetPromptResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, - ReadResourceResultSchema, - Resource, - ResourceTemplate, - Root, - ServerNotification, - Tool, - LoggingLevel, - Task, - GetTaskResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { - AnySchema, - SchemaOutput, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; -import { - hasValidMetaName, - hasValidMetaPrefix, - isReservedMetaKey, -} from "@/utils/metaUtils"; -import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; -import { OAuthStateMachine } from "./lib/oauth-state-machine"; -import { cacheToolOutputSchemas } from "./utils/schemaUtils"; -import { cleanParams } from "./utils/paramUtils"; -import type { JsonSchemaType } from "./utils/jsonUtils"; -import React, { - Suspense, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { useConnection } from "./lib/hooks/useConnection"; -import { - useDraggablePane, - useDraggableSidebar, -} from "./lib/hooks/useDraggablePane"; - -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { - AppWindow, - Bell, - Files, - FolderTree, - Hammer, - Hash, - Key, - ListTodo, - MessageSquare, - Settings, -} from "lucide-react"; - -import { z } from "zod"; -import "./App.css"; -import AuthDebugger from "./components/AuthDebugger"; -import ConsoleTab from "./components/ConsoleTab"; -import HistoryAndNotifications from "./components/HistoryAndNotifications"; -import PingTab from "./components/PingTab"; -import PromptsTab, { Prompt } from "./components/PromptsTab"; -import ResourcesTab from "./components/ResourcesTab"; -import RootsTab from "./components/RootsTab"; -import SamplingTab, { PendingRequest } from "./components/SamplingTab"; -import Sidebar from "./components/Sidebar"; -import ToolsTab from "./components/ToolsTab"; -import TasksTab from "./components/TasksTab"; -import AppsTab from "./components/AppsTab"; -import { InspectorConfig } from "./lib/configurationTypes"; -import { - getMCPProxyAddress, - getMCPProxyAuthToken, - getInitialSseUrl, - getInitialTransportType, - getInitialCommand, - getInitialArgs, - initializeInspectorConfig, - saveInspectorConfig, - getMCPTaskTtl, -} from "./utils/configUtils"; -import ElicitationTab, { - PendingElicitationRequest, - ElicitationResponse, -} from "./components/ElicitationTab"; -import { - CustomHeaders, - migrateFromLegacyAuth, -} from "./lib/types/customHeaders"; -import MetadataTab from "./components/MetadataTab"; - -const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; - -const filterReservedMetadata = ( - metadata: Record, -): Record => { - return Object.entries(metadata).reduce>( - (acc, [key, value]) => { - if ( - !isReservedMetaKey(key) && - hasValidMetaPrefix(key) && - hasValidMetaName(key) - ) { - acc[key] = value; - } - return acc; - }, - {}, - ); -}; - -const App = () => { - const [resources, setResources] = useState([]); - const [resourceTemplates, setResourceTemplates] = useState< - ResourceTemplate[] - >([]); - const [resourceContent, setResourceContent] = useState(""); - const [resourceContentMap, setResourceContentMap] = useState< - Record - >({}); - const [fetchingResources, setFetchingResources] = useState>( - new Set(), - ); - const [prompts, setPrompts] = useState([]); - const [promptContent, setPromptContent] = useState(""); - const [tools, setTools] = useState([]); - const [tasks, setTasks] = useState([]); - const [toolResult, setToolResult] = - useState(null); - const [errors, setErrors] = useState>({ - resources: null, - prompts: null, - tools: null, - tasks: null, - }); - const [command, setCommand] = useState(getInitialCommand); - const [args, setArgs] = useState(getInitialArgs); - - const [sseUrl, setSseUrl] = useState(getInitialSseUrl); - const [transportType, setTransportType] = useState< - "stdio" | "sse" | "streamable-http" - >(getInitialTransportType); - const [connectionType, setConnectionType] = useState<"direct" | "proxy">( - () => { - return ( - (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || - "proxy" - ); - }, - ); - const [logLevel, setLogLevel] = useState("debug"); - const [notifications, setNotifications] = useState([]); - const [roots, setRoots] = useState([]); - const [env, setEnv] = useState>({}); - - const [config, setConfig] = useState(() => - initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY), - ); - const [bearerToken, setBearerToken] = useState(() => { - return localStorage.getItem("lastBearerToken") || ""; - }); - - const [headerName, setHeaderName] = useState(() => { - return localStorage.getItem("lastHeaderName") || ""; - }); - - const [oauthClientId, setOauthClientId] = useState(() => { - return localStorage.getItem("lastOauthClientId") || ""; - }); - - const [oauthScope, setOauthScope] = useState(() => { - return localStorage.getItem("lastOauthScope") || ""; - }); - - const [oauthClientSecret, setOauthClientSecret] = useState(() => { - return localStorage.getItem("lastOauthClientSecret") || ""; - }); - - // Custom headers state with migration from legacy auth - const [customHeaders, setCustomHeaders] = useState(() => { - const savedHeaders = localStorage.getItem("lastCustomHeaders"); - if (savedHeaders) { - try { - return JSON.parse(savedHeaders); - } catch (error) { - console.warn( - `Failed to parse custom headers: "${savedHeaders}", will try legacy migration`, - error, - ); - // Fall back to migration if JSON parsing fails - } - } - - // Migrate from legacy auth if available - const legacyToken = localStorage.getItem("lastBearerToken") || ""; - const legacyHeaderName = localStorage.getItem("lastHeaderName") || ""; - - if (legacyToken) { - return migrateFromLegacyAuth(legacyToken, legacyHeaderName); - } - - // Default to empty array - return [ - { - name: "Authorization", - value: "Bearer ", - enabled: false, - }, - ]; - }); - - const [pendingSampleRequests, setPendingSampleRequests] = useState< - Array< - PendingRequest & { - resolve: (result: CreateMessageResult) => void; - reject: (error: Error) => void; - } - > - >([]); - const [pendingElicitationRequests, setPendingElicitationRequests] = useState< - Array< - PendingElicitationRequest & { - resolve: (response: ElicitationResponse) => void; - decline: (error: Error) => void; - } - > - >([]); - const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - - const [authState, setAuthState] = - useState(EMPTY_DEBUGGER_STATE); - - // Metadata state - persisted in localStorage - const [metadata, setMetadata] = useState>(() => { - const savedMetadata = localStorage.getItem("lastMetadata"); - if (savedMetadata) { - try { - const parsed = JSON.parse(savedMetadata); - if (parsed && typeof parsed === "object") { - return filterReservedMetadata(parsed); - } - } catch (error) { - console.warn("Failed to parse saved metadata:", error); - } - } - return {}; - }); - - const updateAuthState = (updates: Partial) => { - setAuthState((prev) => ({ ...prev, ...updates })); - }; - - const handleMetadataChange = (newMetadata: Record) => { - const sanitizedMetadata = filterReservedMetadata(newMetadata); - setMetadata(sanitizedMetadata); - localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata)); - }; - const nextRequestId = useRef(0); - const rootsRef = useRef([]); - - const [selectedResource, setSelectedResource] = useState( - null, - ); - const [resourceSubscriptions, setResourceSubscriptions] = useState< - Set - >(new Set()); - - const [selectedPrompt, setSelectedPrompt] = useState(null); - const [selectedTool, setSelectedTool] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [isPollingTask, setIsPollingTask] = useState(false); - const [nextResourceCursor, setNextResourceCursor] = useState< - string | undefined - >(); - const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< - string | undefined - >(); - const [nextPromptCursor, setNextPromptCursor] = useState< - string | undefined - >(); - const [nextToolCursor, setNextToolCursor] = useState(); - const [nextTaskCursor, setNextTaskCursor] = useState(); - const progressTokenRef = useRef(0); - - const [activeTab, setActiveTab] = useState(() => { - const hash = window.location.hash.slice(1); - const initialTab = hash || "resources"; - return initialTab; - }); - - const currentTabRef = useRef(activeTab); - const lastToolCallOriginTabRef = useRef(activeTab); - - useEffect(() => { - currentTabRef.current = activeTab; - }, [activeTab]); - - const navigateToOriginatingTab = (originatingTab?: string) => { - if (!originatingTab) return; - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - if (!validTabs.includes(originatingTab)) return; - - setActiveTab(originatingTab); - window.location.hash = originatingTab; - - setTimeout(() => { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - }, 100); - }; - - const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); - const { - width: sidebarWidth, - isDragging: isSidebarDragging, - handleDragStart: handleSidebarDragStart, - } = useDraggableSidebar(320); - - const selectedTaskRef = useRef(null); - useEffect(() => { - selectedTaskRef.current = selectedTask; - }, [selectedTask]); - - const { - connectionStatus, - serverCapabilities, - serverImplementation, - mcpClient, - requestHistory, - clearRequestHistory, - makeRequest, - cancelTask: cancelMcpTask, - listTasks: listMcpTasks, - sendNotification, - handleCompletion, - completionsSupported, - connect: connectMcpServer, - disconnect: disconnectMcpServer, - } = useConnection({ - transportType, - command, - args, - sseUrl, - env, - customHeaders, - oauthClientId, - oauthClientSecret, - oauthScope, - config, - connectionType, - onNotification: (notification) => { - setNotifications((prev) => [...prev, notification as ServerNotification]); - - if (notification.method === "notifications/tasks/list_changed") { - void listTasks(); - } - - if (notification.method === "notifications/tasks/status") { - const task = notification.params as unknown as Task; - setTasks((prev) => { - const exists = prev.some((t) => t.taskId === task.taskId); - if (exists) { - return prev.map((t) => (t.taskId === task.taskId ? task : t)); - } else { - return [task, ...prev]; - } - }); - if (selectedTaskRef.current?.taskId === task.taskId) { - setSelectedTask(task); - } - } - }, - onPendingRequest: (request, resolve, reject) => { - const currentTab = lastToolCallOriginTabRef.current; - setPendingSampleRequests((prev) => [ - ...prev, - { - id: nextRequestId.current++, - request, - originatingTab: currentTab, - resolve, - reject, - }, - ]); - - setActiveTab("sampling"); - window.location.hash = "sampling"; - }, - onElicitationRequest: (request, resolve) => { - const currentTab = lastToolCallOriginTabRef.current; - - setPendingElicitationRequests((prev) => [ - ...prev, - { - id: nextRequestId.current++, - request: { - id: nextRequestId.current, - message: request.params.message, - requestedSchema: request.params.requestedSchema, - }, - originatingTab: currentTab, - resolve, - decline: (error: Error) => { - console.error("Elicitation request rejected:", error); - }, - }, - ]); - - setActiveTab("elicitations"); - window.location.hash = "elicitations"; - }, - getRoots: () => rootsRef.current, - defaultLoggingLevel: logLevel, - metadata, - }); - - useEffect(() => { - if (serverCapabilities) { - const hash = window.location.hash.slice(1); - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - const isValidTab = validTabs.includes(hash); - - if (!isValidTab) { - const defaultTab = serverCapabilities?.resources - ? "resources" - : serverCapabilities?.prompts - ? "prompts" - : serverCapabilities?.tools - ? "tools" - : serverCapabilities?.tasks - ? "tasks" - : "ping"; - - setActiveTab(defaultTab); - window.location.hash = defaultTab; - } - } - }, [serverCapabilities]); - - useEffect(() => { - if (mcpClient && activeTab === "tasks") { - void listTasks(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mcpClient, activeTab]); - - useEffect(() => { - if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) { - void listTools(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mcpClient, activeTab, serverCapabilities?.tools]); - - useEffect(() => { - localStorage.setItem("lastCommand", command); - }, [command]); - - useEffect(() => { - localStorage.setItem("lastArgs", args); - }, [args]); - - useEffect(() => { - localStorage.setItem("lastSseUrl", sseUrl); - }, [sseUrl]); - - useEffect(() => { - localStorage.setItem("lastTransportType", transportType); - }, [transportType]); - - useEffect(() => { - localStorage.setItem("lastConnectionType", connectionType); - }, [connectionType]); - - useEffect(() => { - if (bearerToken) { - localStorage.setItem("lastBearerToken", bearerToken); - } else { - localStorage.removeItem("lastBearerToken"); - } - }, [bearerToken]); - - useEffect(() => { - if (headerName) { - localStorage.setItem("lastHeaderName", headerName); - } else { - localStorage.removeItem("lastHeaderName"); - } - }, [headerName]); - - useEffect(() => { - localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); - }, [customHeaders]); - - // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists - useEffect(() => { - if (customHeaders.length === 0 && (bearerToken || headerName)) { - const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName); - if (migratedHeaders.length > 0) { - setCustomHeaders(migratedHeaders); - // Clear legacy auth after migration - setBearerToken(""); - setHeaderName(""); - } - } - }, [bearerToken, headerName, customHeaders, setCustomHeaders]); - - useEffect(() => { - localStorage.setItem("lastOauthClientId", oauthClientId); - }, [oauthClientId]); - - useEffect(() => { - localStorage.setItem("lastOauthScope", oauthScope); - }, [oauthScope]); - - useEffect(() => { - localStorage.setItem("lastOauthClientSecret", oauthClientSecret); - }, [oauthClientSecret]); - - useEffect(() => { - saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); - }, [config]); - - const onOAuthConnect = useCallback( - (serverUrl: string) => { - setSseUrl(serverUrl); - setIsAuthDebuggerVisible(false); - void connectMcpServer(); - }, - [connectMcpServer], - ); - - const onOAuthDebugConnect = useCallback( - async ({ - authorizationCode, - errorMsg, - restoredState, - }: { - authorizationCode?: string; - errorMsg?: string; - restoredState?: AuthDebuggerState; - }) => { - setIsAuthDebuggerVisible(true); - - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), - }); - return; - } - - if (restoredState && authorizationCode) { - let currentState: AuthDebuggerState = { - ...restoredState, - authorizationCode, - oauthStep: "token_request", - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }; - - try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); - - while ( - currentState.oauthStep !== "complete" && - currentState.oauthStep !== "authorization_code" - ) { - await stateMachine.executeStep(currentState); - } - - if (currentState.oauthStep === "complete") { - updateAuthState({ - ...currentState, - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, - isInitiatingAuth: false, - }); - } - } catch (error) { - console.error("OAuth continuation error:", error); - updateAuthState({ - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - }, - isInitiatingAuth: false, - }); - } - } else if (authorizationCode) { - updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - }, - [sseUrl], - ); - - useEffect(() => { - const loadOAuthTokens = async () => { - try { - if (sseUrl) { - const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); - const tokens = sessionStorage.getItem(key); - if (tokens) { - const parsedTokens = await OAuthTokensSchema.parseAsync( - JSON.parse(tokens), - ); - updateAuthState({ - oauthTokens: parsedTokens, - oauthStep: "complete", - }); - } - } - } catch (error) { - console.error("Error loading OAuth tokens:", error); - } - }; - - loadOAuthTokens(); - }, [sseUrl]); - - useEffect(() => { - const headers: HeadersInit = {}; - const { token: proxyAuthToken, header: proxyAuthTokenHeader } = - getMCPProxyAuthToken(config); - if (proxyAuthToken) { - headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; - } - - fetch(`${getMCPProxyAddress(config)}/config`, { headers }) - .then((response) => response.json()) - .then((data) => { - setEnv(data.defaultEnvironment); - if (data.defaultCommand) { - setCommand(data.defaultCommand); - } - if (data.defaultArgs) { - setArgs(data.defaultArgs); - } - if (data.defaultTransport) { - setTransportType( - data.defaultTransport as "stdio" | "sse" | "streamable-http", - ); - } - if (data.defaultServerUrl) { - setSseUrl(data.defaultServerUrl); - } - }) - .catch((error) => - console.error("Error fetching default environment:", error), - ); - }, [config]); - - useEffect(() => { - rootsRef.current = roots; - }, [roots]); - - useEffect(() => { - if (mcpClient && !window.location.hash) { - const defaultTab = serverCapabilities?.resources - ? "resources" - : serverCapabilities?.prompts - ? "prompts" - : serverCapabilities?.tools - ? "tools" - : serverCapabilities?.tasks - ? "tasks" - : "ping"; - window.location.hash = defaultTab; - } else if (!mcpClient && window.location.hash) { - // Clear hash when disconnected - completely remove the fragment - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - } - }, [mcpClient, serverCapabilities]); - - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.slice(1); - if (hash && hash !== activeTab) { - setActiveTab(hash); - } - }; - - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, [activeTab]); - - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - - navigateToOriginatingTab(request?.originatingTab); - - return prev.filter((r) => r.id !== id); - }); - }; - - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - - navigateToOriginatingTab(request?.originatingTab); - - return prev.filter((r) => r.id !== id); - }); - }; - - const handleResolveElicitation = ( - id: number, - response: ElicitationResponse, - ) => { - setPendingElicitationRequests((prev) => { - const request = prev.find((r) => r.id === id); - if (request) { - request.resolve(response); - - if (request.originatingTab) { - const originatingTab = request.originatingTab; - - const validTabs = [ - ...(serverCapabilities?.resources ? ["resources"] : []), - ...(serverCapabilities?.prompts ? ["prompts"] : []), - ...(serverCapabilities?.tools ? ["tools"] : []), - ...(serverCapabilities?.tasks ? ["tasks"] : []), - "apps", - "ping", - "sampling", - "elicitations", - "roots", - "auth", - "metadata", - ]; - - if (validTabs.includes(originatingTab)) { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - - setTimeout(() => { - setActiveTab(originatingTab); - window.location.hash = originatingTab; - }, 100); - } - } - } - return prev.filter((r) => r.id !== id); - }); - }; - - const clearError = (tabKey: keyof typeof errors) => { - setErrors((prev) => ({ ...prev, [tabKey]: null })); - }; - - const sendMCPRequest = async ( - request: ClientRequest, - schema: T, - tabKey?: keyof typeof errors, - ): Promise> => { - try { - const response = await makeRequest(request, schema); - if (tabKey !== undefined) { - clearError(tabKey); - } - return response; - } catch (e) { - const errorString = (e as Error).message ?? String(e); - if (tabKey !== undefined) { - setErrors((prev) => ({ - ...prev, - [tabKey]: errorString, - })); - } - throw e; - } - }; - - const listResources = async () => { - const response = await sendMCPRequest( - { - method: "resources/list" as const, - params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, - }, - ListResourcesResultSchema, - "resources", - ); - setResources(resources.concat(response.resources ?? [])); - setNextResourceCursor(response.nextCursor); - }; - - const listResourceTemplates = async () => { - const response = await sendMCPRequest( - { - method: "resources/templates/list" as const, - params: nextResourceTemplateCursor - ? { cursor: nextResourceTemplateCursor } - : {}, - }, - ListResourceTemplatesResultSchema, - "resources", - ); - setResourceTemplates( - resourceTemplates.concat(response.resourceTemplates ?? []), - ); - setNextResourceTemplateCursor(response.nextCursor); - }; - - const getPrompt = async (name: string, args: Record = {}) => { - lastToolCallOriginTabRef.current = currentTabRef.current; - - const response = await sendMCPRequest( - { - method: "prompts/get" as const, - params: { name, arguments: args }, - }, - GetPromptResultSchema, - "prompts", - ); - setPromptContent(JSON.stringify(response, null, 2)); - }; - - 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; - - try { - const response = await sendMCPRequest( - { - method: "resources/read" as const, - params: { uri }, - }, - ReadResourceResultSchema, - "resources", - ); - console.log("[App] Resource read response:", { - uri, - responseLength: JSON.stringify(response).length, - hasContents: !!(response as { contents?: unknown[] }).contents, - }); - const content = JSON.stringify(response, null, 2); - setResourceContent(content); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: content, - })); - } catch (error) { - console.error(`[App] Failed to read resource ${uri}:`, error); - const errorString = (error as Error).message ?? String(error); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: JSON.stringify({ error: errorString }), - })); - } finally { - setFetchingResources((prev) => { - const next = new Set(prev); - next.delete(uri); - return next; - }); - } - }; - - const subscribeToResource = async (uri: string) => { - if (!resourceSubscriptions.has(uri)) { - await sendMCPRequest( - { - method: "resources/subscribe" as const, - params: { uri }, - }, - z.object({}), - "resources", - ); - const clone = new Set(resourceSubscriptions); - clone.add(uri); - setResourceSubscriptions(clone); - } - }; - - const unsubscribeFromResource = async (uri: string) => { - if (resourceSubscriptions.has(uri)) { - await sendMCPRequest( - { - method: "resources/unsubscribe" as const, - params: { uri }, - }, - z.object({}), - "resources", - ); - const clone = new Set(resourceSubscriptions); - clone.delete(uri); - setResourceSubscriptions(clone); - } - }; - - const listPrompts = async () => { - const response = await sendMCPRequest( - { - method: "prompts/list" as const, - params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, - }, - ListPromptsResultSchema, - "prompts", - ); - setPrompts(response.prompts); - setNextPromptCursor(response.nextCursor); - }; - - const listTools = async () => { - const response = await sendMCPRequest( - { - method: "tools/list" as const, - params: nextToolCursor ? { cursor: nextToolCursor } : {}, - }, - ListToolsResultSchema, - "tools", - ); - setTools(response.tools); - setNextToolCursor(response.nextCursor); - cacheToolOutputSchemas(response.tools); - }; - - const callTool = async ( - name: string, - params: Record, - toolMetadata?: Record, - runAsTask?: boolean, - ) => { - lastToolCallOriginTabRef.current = currentTabRef.current; - - try { - // Find the tool schema to clean parameters properly - const tool = tools.find((t) => t.name === name); - const cleanedParams = tool?.inputSchema - ? cleanParams(params, tool.inputSchema as JsonSchemaType) - : params; - - // Merge general metadata with tool-specific metadata - // Tool-specific metadata takes precedence over general metadata - const mergedMetadata = { - ...metadata, // General metadata - progressToken: progressTokenRef.current++, - ...toolMetadata, // Tool-specific metadata - }; - - const request: ClientRequest = { - method: "tools/call" as const, - params: { - name, - arguments: cleanedParams, - _meta: mergedMetadata, - }, - }; - - if (runAsTask) { - request.params = { - ...request.params, - task: { - ttl: getMCPTaskTtl(config), - }, - }; - } - - const response = await sendMCPRequest( - request, - CompatibilityCallToolResultSchema, - "tools", - ); - - // Check if this was a task-augmented request that returned a task reference - // The server returns { task: { taskId, status, ... } } when a task is created - const isTaskResult = ( - res: unknown, - ): res is { - task: { taskId: string; status: string; pollInterval: number }; - } => - !!res && - typeof res === "object" && - "task" in res && - !!res.task && - typeof res.task === "object" && - "taskId" in res.task; - - if (runAsTask && isTaskResult(response)) { - const taskId = response.task.taskId; - const pollInterval = response.task.pollInterval; - // Set polling state BEFORE setting tool result for proper UI update - setIsPollingTask(true); - // Safely extract any _meta from the original response (if present) - const initialResponseMeta = - response && - typeof response === "object" && - "_meta" in (response as Record) - ? ((response as { _meta?: Record })._meta ?? {}) - : undefined; - setToolResult({ - content: [ - { - type: "text", - text: `Task created: ${taskId}. Polling for status...`, - }, - ], - _meta: { - ...(initialResponseMeta || {}), - "io.modelcontextprotocol/related-task": { taskId }, - }, - } as CompatibilityCallToolResult); - - // Polling loop - let taskCompleted = false; - while (!taskCompleted) { - try { - // Wait for 1 second before polling - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - - const taskStatus = await sendMCPRequest( - { - method: "tasks/get", - params: { taskId }, - }, - GetTaskResultSchema, - ); - - if ( - taskStatus.status === "completed" || - taskStatus.status === "failed" || - taskStatus.status === "cancelled" - ) { - taskCompleted = true; - console.log( - `Polling complete for task ${taskId}: ${taskStatus.status}`, - ); - - if (taskStatus.status === "completed") { - console.log(`Fetching result for task ${taskId}`); - const result = await sendMCPRequest( - { - method: "tasks/result", - params: { taskId }, - }, - CompatibilityCallToolResultSchema, - ); - console.log(`Result received for task ${taskId}:`, result); - setToolResult(result as CompatibilityCallToolResult); - - // Refresh tasks list to show completed state - void listTasks(); - } else { - setToolResult({ - content: [ - { - type: "text", - text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`, - }, - ], - isError: true, - }); - // Refresh tasks list to show failed/cancelled state - void listTasks(); - } - } else { - // Update status message while polling - // Safely extract any _meta from the original response (if present) - const pollingResponseMeta = - response && - typeof response === "object" && - "_meta" in (response as Record) - ? ((response as { _meta?: Record })._meta ?? - {}) - : undefined; - setToolResult({ - content: [ - { - type: "text", - text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`, - }, - ], - _meta: { - ...(pollingResponseMeta || {}), - "io.modelcontextprotocol/related-task": { taskId }, - }, - } as CompatibilityCallToolResult); - // Refresh tasks list to show progress - void listTasks(); - } - } catch (pollingError) { - console.error("Error polling task status:", pollingError); - setToolResult({ - content: [ - { - type: "text", - text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`, - }, - ], - isError: true, - }); - taskCompleted = true; - } - } - setIsPollingTask(false); - } else { - setToolResult(response as CompatibilityCallToolResult); - } - // Clear any validation errors since tool execution completed - setErrors((prev) => ({ ...prev, tools: null })); - } catch (e) { - const toolResult: CompatibilityCallToolResult = { - content: [ - { - type: "text", - text: (e as Error).message ?? String(e), - }, - ], - isError: true, - }; - setToolResult(toolResult); - // Clear validation errors - tool execution errors are shown in ToolResults - setErrors((prev) => ({ ...prev, tools: null })); - } - }; - - const listTasks = useCallback(async () => { - try { - const response = await listMcpTasks(nextTaskCursor); - setTasks(response.tasks); - setNextTaskCursor(response.nextCursor); - // Inline error clear to avoid extra dependency on clearError - setErrors((prev) => ({ ...prev, tasks: null })); - } catch (e) { - setErrors((prev) => ({ - ...prev, - tasks: (e as Error).message ?? String(e), - })); - } - }, [listMcpTasks, nextTaskCursor]); - - const cancelTask = async (taskId: string) => { - try { - const response = await cancelMcpTask(taskId); - setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t))); - if (selectedTask?.taskId === taskId) { - setSelectedTask(response); - } - clearError("tasks"); - } catch (e) { - setErrors((prev) => ({ - ...prev, - tasks: (e as Error).message ?? String(e), - })); - } - }; - - const handleRootsChange = async () => { - await sendNotification({ method: "notifications/roots/list_changed" }); - }; - - const handleClearNotifications = () => { - setNotifications([]); - }; - - const sendLogLevelRequest = async (level: LoggingLevel) => { - await sendMCPRequest( - { - method: "logging/setLevel" as const, - params: { level }, - }, - z.object({}), - ); - setLogLevel(level); - }; - - const AuthDebuggerWrapper = () => ( - - setIsAuthDebuggerVisible(false)} - authState={authState} - updateAuthState={updateAuthState} - /> - - ); - - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); - } - - if (window.location.pathname === "/oauth/callback/debug") { - const OAuthDebugCallback = React.lazy( - () => import("./components/OAuthDebugCallback"), - ); - return ( - Loading...}> - - - ); - } - - 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 -

-
- { - 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); - 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); - }} - /> - { - 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(); - }} - 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? -

- -
-
- )} -
-
-
-
-
-
- -
-
-
-
- ); -}; - -export default App; diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx deleted file mode 100644 index 7458c2055..000000000 --- a/client/src/__tests__/App.config.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { render, waitFor } from "@testing-library/react"; -import App from "../App"; -import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants"; -import { InspectorConfig } from "../lib/configurationTypes"; -import * as configUtils from "../utils/configUtils"; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -// Mock the config utils -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn((config: InspectorConfig) => ({ - token: config.MCP_PROXY_AUTH_TOKEN.value, - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => DEFAULT_INSPECTOR_CONFIG), - saveInspectorConfig: jest.fn(), -})); - -// Get references to the mocked functions -const mockGetMCPProxyAuthToken = configUtils.getMCPProxyAuthToken as jest.Mock; -const mockInitializeInspectorConfig = - configUtils.initializeInspectorConfig as jest.Mock; - -// Mock other dependencies -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: () => ({ - connectionStatus: "disconnected", - serverCapabilities: null, - mcpClient: null, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: 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
, -})); - -// Mock fetch -global.fetch = jest.fn(); - -describe("App - Config Endpoint", () => { - beforeEach(() => { - jest.clearAllMocks(); - (global.fetch as jest.Mock).mockResolvedValue({ - json: () => - Promise.resolve({ - defaultEnvironment: { TEST_ENV: "test" }, - defaultCommand: "test-command", - defaultArgs: "test-args", - }), - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - - // Reset getMCPProxyAuthToken to default behavior - mockGetMCPProxyAuthToken.mockImplementation((config: InspectorConfig) => ({ - token: config.MCP_PROXY_AUTH_TOKEN.value, - header: "X-MCP-Proxy-Auth", - })); - }); - - test("sends X-MCP-Proxy-Auth header when fetching config with proxy auth token", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - // Mock initializeInspectorConfig to return our test config - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: { - "X-MCP-Proxy-Auth": "Bearer test-proxy-token", - }, - }, - ); - }); - }); - - test("does not send auth header when proxy auth token is empty", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "", - }, - }; - - // Mock initializeInspectorConfig to return our test config - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: {}, - }, - ); - }); - }); - - test("uses custom header name if getMCPProxyAuthToken returns different header", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - // Mock to return a custom header name - mockGetMCPProxyAuthToken.mockReturnValue({ - token: "test-proxy-token", - header: "X-Custom-Auth", - }); - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - { - headers: { - "X-Custom-Auth": "Bearer test-proxy-token", - }, - }, - ); - }); - }); - - test("config endpoint response updates app state", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - render(); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - // Verify the fetch was called with correct parameters - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:6277/config", - expect.objectContaining({ - headers: expect.objectContaining({ - "X-MCP-Proxy-Auth": "Bearer test-proxy-token", - }), - }), - ); - }); - - test("handles config endpoint errors gracefully", async () => { - const mockConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }; - - mockInitializeInspectorConfig.mockReturnValue(mockConfig); - - // Mock fetch to reject - (global.fetch as jest.Mock).mockRejectedValue(new Error("Network error")); - - // Spy on console.error - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); - - render(); - - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error fetching default environment:", - expect.any(Error), - ); - }); - - consoleErrorSpy.mockRestore(); - }); -}); diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx deleted file mode 100644 index 4713bef9a..000000000 --- a/client/src/__tests__/App.routing.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { render, waitFor } from "@testing-library/react"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -// Mock the config utils -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -// Default connection state is disconnected -const disconnectedConnectionState = { - connectionStatus: "disconnected" as const, - serverCapabilities: null, - mcpClient: null, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - serverImplementation: null, -}; - -// Connected state for tests that need an active connection -const connectedConnectionState = { - ...disconnectedConnectionState, - connectionStatus: "connected" as const, - serverCapabilities: {}, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, -}; - -// 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
, -})); - -// Mock fetch -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -// Use an empty module mock, so that mock state can be reset between tests. -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - URL Fragment Routing", () => { - const mockUseConnection = jest.mocked(useConnection); - - beforeEach(() => { - jest.restoreAllMocks(); - - // Inspector starts disconnected. - mockUseConnection.mockReturnValue(disconnectedConnectionState); - }); - - test("does not set hash when starting disconnected", async () => { - render(); - - await waitFor(() => { - expect(window.location.hash).toBe(""); - }); - }); - - test("sets default hash based on server capabilities priority", async () => { - // Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth - // - // Server capabilities determine the first three tabs; if none are present, falls back to Ping. - - const testCases = [ - { - capabilities: { resources: { listChanged: true, subscribe: true } }, - expected: "#resources", - }, - { - capabilities: { prompts: { listChanged: true, subscribe: true } }, - expected: "#prompts", - }, - { - capabilities: { tools: { listChanged: true, subscribe: true } }, - expected: "#tools", - }, - { capabilities: {}, expected: "#ping" }, - ]; - - const { rerender } = render(); - - for (const { capabilities, expected } of testCases) { - window.location.hash = ""; - mockUseConnection.mockReturnValue({ - ...connectedConnectionState, - serverCapabilities: capabilities, - }); - - rerender(); - - await waitFor(() => { - expect(window.location.hash).toBe(expected); - }); - } - }); - - test("clears hash when disconnected", async () => { - // Start with a hash set (simulating a connection) - window.location.hash = "#resources"; - - // App starts disconnected (default mock) - render(); - - // Should clear the hash when disconnected - await waitFor(() => { - expect(window.location.hash).toBe(""); - }); - }); -}); diff --git a/client/src/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx deleted file mode 100644 index 70a42a92f..000000000 --- a/client/src/__tests__/App.samplingNavigation.test.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import App from "../App"; -import { useConnection } from "../lib/hooks/useConnection"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { - CreateMessageRequest, - CreateMessageResult, -} from "@modelcontextprotocol/sdk/types.js"; - -type OnPendingRequestHandler = ( - request: CreateMessageRequest, - resolve: (result: CreateMessageResult) => void, - reject: (error: Error) => void, -) => void; - -type SamplingRequestMockProps = { - request: { id: number }; - onApprove: (id: number, result: CreateMessageResult) => void; - onReject: (id: number) => void; -}; - -type UseConnectionReturn = ReturnType; - -// Mock auth dependencies first -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), -})); - -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - -jest.mock("../lib/auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue(null), - clear: jest.fn(), - })), - DebugInspectorOAuthClientProvider: jest.fn(), -})); - -jest.mock("../utils/configUtils", () => ({ - ...jest.requireActual("../utils/configUtils"), - getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), - getMCPProxyAuthToken: jest.fn(() => ({ - token: "", - header: "X-MCP-Proxy-Auth", - })), - getInitialTransportType: jest.fn(() => "stdio"), - getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), - getInitialCommand: jest.fn(() => "mcp-server-everything"), - getInitialArgs: jest.fn(() => ""), - initializeInspectorConfig: jest.fn(() => ({})), - saveInspectorConfig: jest.fn(), -})); - -jest.mock("../lib/hooks/useDraggablePane", () => ({ - useDraggablePane: () => ({ - height: 300, - handleDragStart: jest.fn(), - }), - useDraggableSidebar: () => ({ - width: 320, - isDragging: false, - handleDragStart: jest.fn(), - }), -})); - -jest.mock("../components/Sidebar", () => ({ - __esModule: true, - default: () =>
Sidebar
, -})); - -jest.mock("../lib/hooks/useToast", () => ({ - useToast: () => ({ toast: jest.fn() }), -})); - -// Keep the test focused on navigation; avoid DynamicJsonForm/schema complexity. -jest.mock("../components/SamplingRequest", () => ({ - __esModule: true, - default: ({ request, onApprove, onReject }: SamplingRequestMockProps) => ( -
-
sampling-request-{request.id}
- - -
- ), -})); - -// Mock fetch -global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); - -jest.mock("../lib/hooks/useConnection", () => ({ - useConnection: jest.fn(), -})); - -describe("App - Sampling auto-navigation", () => { - const mockUseConnection = jest.mocked(useConnection); - - const baseConnectionState = { - connectionStatus: "connected" as const, - serverCapabilities: { tools: { listChanged: true, subscribe: true } }, - mcpClient: { - request: jest.fn(), - notification: jest.fn(), - close: jest.fn(), - } as unknown as Client, - requestHistory: [], - clearRequestHistory: jest.fn(), - makeRequest: jest.fn(), - sendNotification: jest.fn(), - handleCompletion: jest.fn(), - completionsSupported: false, - connect: jest.fn(), - disconnect: jest.fn(), - serverImplementation: null, - cancelTask: jest.fn(), - listTasks: jest.fn(), - }; - - beforeEach(() => { - jest.restoreAllMocks(); - window.location.hash = "#tools"; - }); - - test("switches to #sampling when a sampling request arrives and switches back to #tools after approve", async () => { - let capturedOnPendingRequest: OnPendingRequestHandler | undefined; - - mockUseConnection.mockImplementation((options) => { - capturedOnPendingRequest = ( - options as { onPendingRequest?: OnPendingRequestHandler } - ).onPendingRequest; - return baseConnectionState as unknown as UseConnectionReturn; - }); - - render(); - - // Ensure we start on tools. - await waitFor(() => { - expect(window.location.hash).toBe("#tools"); - }); - - const resolve = jest.fn(); - const reject = jest.fn(); - - act(() => { - if (!capturedOnPendingRequest) { - throw new Error("Expected onPendingRequest to be provided"); - } - - capturedOnPendingRequest( - { - method: "sampling/createMessage", - params: { messages: [], maxTokens: 1 }, - }, - resolve, - reject, - ); - }); - - await waitFor(() => { - expect(window.location.hash).toBe("#sampling"); - expect(screen.getByTestId("sampling-request")).toBeTruthy(); - }); - - fireEvent.click(screen.getByText("Approve")); - - await waitFor(() => { - expect(resolve).toHaveBeenCalled(); - expect(window.location.hash).toBe("#tools"); - }); - }); - - test("switches back to #tools after reject", async () => { - let capturedOnPendingRequest: OnPendingRequestHandler | undefined; - - mockUseConnection.mockImplementation((options) => { - capturedOnPendingRequest = ( - options as { onPendingRequest?: OnPendingRequestHandler } - ).onPendingRequest; - return baseConnectionState as unknown as UseConnectionReturn; - }); - - render(); - - await waitFor(() => { - expect(window.location.hash).toBe("#tools"); - }); - - const resolve = jest.fn(); - const reject = jest.fn(); - - act(() => { - if (!capturedOnPendingRequest) { - throw new Error("Expected onPendingRequest to be provided"); - } - - capturedOnPendingRequest( - { - method: "sampling/createMessage", - params: { messages: [], maxTokens: 1 }, - }, - resolve, - reject, - ); - }); - - await waitFor(() => { - expect(window.location.hash).toBe("#sampling"); - expect(screen.getByTestId("sampling-request")).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole("button", { name: /Reject/i })); - - await waitFor(() => { - expect(reject).toHaveBeenCalled(); - expect(window.location.hash).toBe("#tools"); - }); - }); -}); diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx deleted file mode 100644 index 6252c1161..000000000 --- a/client/src/components/AuthDebugger.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useCallback, useMemo, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { DebugInspectorOAuthClientProvider } from "../lib/auth"; -import { AlertCircle } from "lucide-react"; -import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; -import { OAuthFlowProgress } from "./OAuthFlowProgress"; -import { OAuthStateMachine } from "../lib/oauth-state-machine"; -import { SESSION_KEYS } from "../lib/constants"; -import { validateRedirectUrl } from "@/utils/urlValidation"; - -export interface AuthDebuggerProps { - serverUrl: string; - onBack: () => void; - authState: AuthDebuggerState; - updateAuthState: (updates: Partial) => void; -} - -interface StatusMessageProps { - message: { type: "error" | "success" | "info"; message: string }; -} - -const StatusMessage = ({ message }: StatusMessageProps) => { - let bgColor: string; - let textColor: string; - let borderColor: string; - - switch (message.type) { - case "error": - bgColor = "bg-red-50"; - textColor = "text-red-700"; - borderColor = "border-red-200"; - break; - case "success": - bgColor = "bg-green-50"; - textColor = "text-green-700"; - borderColor = "border-green-200"; - break; - case "info": - default: - bgColor = "bg-blue-50"; - textColor = "text-blue-700"; - borderColor = "border-blue-200"; - break; - } - - return ( -
-
- -

{message.message}

-
-
- ); -}; - -const AuthDebugger = ({ - serverUrl: serverUrl, - onBack, - authState, - updateAuthState, -}: AuthDebuggerProps) => { - // Check for existing tokens on mount - useEffect(() => { - if (serverUrl && !authState.oauthTokens) { - const checkTokens = async () => { - try { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); - const existingTokens = await provider.tokens(); - if (existingTokens) { - updateAuthState({ - oauthTokens: existingTokens, - oauthStep: "complete", - }); - } - } catch (error) { - console.error("Failed to load existing OAuth tokens:", error); - } - }; - checkTokens(); - } - }, [serverUrl, updateAuthState, authState.oauthTokens]); - - const startOAuthFlow = useCallback(() => { - if (!serverUrl) { - updateAuthState({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, - }); - return; - } - - updateAuthState({ - oauthStep: "metadata_discovery", - authorizationUrl: null, - statusMessage: null, - latestError: null, - }); - }, [serverUrl, updateAuthState]); - - const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], - ); - - const proceedToNextStep = useCallback(async () => { - if (!serverUrl) return; - - try { - updateAuthState({ - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }); - - await stateMachine.executeStep(authState); - } catch (error) { - console.error("OAuth flow error:", error); - updateAuthState({ - latestError: error instanceof Error ? error : new Error(String(error)), - }); - } finally { - updateAuthState({ isInitiatingAuth: false }); - } - }, [serverUrl, authState, updateAuthState, stateMachine]); - - const handleQuickOAuth = useCallback(async () => { - if (!serverUrl) { - updateAuthState({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, - }); - return; - } - - updateAuthState({ isInitiatingAuth: true, statusMessage: null }); - try { - // Step through the OAuth flow using the state machine instead of the auth() function - let currentState: AuthDebuggerState = { - ...authState, - oauthStep: "metadata_discovery", - authorizationUrl: null, - latestError: null, - }; - - const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { - // Update our temporary state during the process - currentState = { ...currentState, ...updates }; - // But don't call updateAuthState yet - }); - - // Manually step through each stage of the OAuth flow - while (currentState.oauthStep !== "complete") { - await oauthMachine.executeStep(currentState); - // In quick mode, we'll just redirect to the authorization URL - if ( - currentState.oauthStep === "authorization_code" && - currentState.authorizationUrl - ) { - // Validate the URL before redirecting - try { - validateRedirectUrl(currentState.authorizationUrl); - } catch (error) { - updateAuthState({ - ...currentState, - isInitiatingAuth: false, - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`, - }, - }); - return; - } - - // Store the current auth state before redirecting - sessionStorage.setItem( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - JSON.stringify(currentState), - ); - // Open the authorization URL automatically - window.location.href = currentState.authorizationUrl.toString(); - break; - } - } - - // After the flow completes or reaches a user-input step, update the app state - updateAuthState({ - ...currentState, - statusMessage: { - type: "info", - message: - currentState.oauthStep === "complete" - ? "Authentication completed successfully" - : "Please complete authentication in the opened window and enter the code", - }, - }); - } catch (error) { - console.error("OAuth initialization error:", error); - updateAuthState({ - statusMessage: { - type: "error", - message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - }, - }); - } finally { - updateAuthState({ isInitiatingAuth: false }); - } - }, [serverUrl, updateAuthState, authState]); - - const handleClearOAuth = useCallback(() => { - if (serverUrl) { - const serverAuthProvider = new DebugInspectorOAuthClientProvider( - serverUrl, - ); - serverAuthProvider.clear(); - updateAuthState({ - ...EMPTY_DEBUGGER_STATE, - statusMessage: { - type: "success", - message: "OAuth tokens cleared successfully", - }, - }); - - // Clear success message after 3 seconds - setTimeout(() => { - updateAuthState({ statusMessage: null }); - }, 3000); - } - }, [serverUrl, updateAuthState]); - - return ( -
-
-

Authentication Settings

- -
- -
-
-
-

- Configure authentication settings for your MCP server connection. -

- -
-

OAuth Authentication

-

- Use OAuth to securely authenticate with the MCP server. -

- - {authState.statusMessage && ( - - )} - -
- {authState.oauthTokens && ( -
-

Access Token:

-
- {authState.oauthTokens.access_token.substring(0, 25)}... -
-
- )} - -
- - - - - -
- -

- Choose "Guided" for step-by-step instructions or "Quick" for - the standard automatic flow. -

-
-
- - -
-
-
-
- ); -}; - -export default AuthDebugger; diff --git a/client/src/components/ConsoleTab.tsx b/client/src/components/ConsoleTab.tsx deleted file mode 100644 index 8f05f704c..000000000 --- a/client/src/components/ConsoleTab.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { TabsContent } from "@/components/ui/tabs"; - -const ConsoleTab = () => ( - -
-
Welcome to MCP Client Console
- {/* Console output would go here */} -
-
-); - -export default ConsoleTab; diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx deleted file mode 100644 index cf3be2b5c..000000000 --- a/client/src/components/ElicitationTab.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { TabsContent } from "@/components/ui/tabs"; -import { JsonSchemaType } from "@/utils/jsonUtils"; -import ElicitationRequest from "./ElicitationRequest"; - -export interface ElicitationRequestData { - id: number; - message: string; - requestedSchema: JsonSchemaType; -} - -export interface ElicitationResponse { - action: "accept" | "decline" | "cancel"; - content?: Record; -} - -export type PendingElicitationRequest = { - id: number; - request: ElicitationRequestData; - originatingTab?: string; -}; - -export type Props = { - pendingRequests: PendingElicitationRequest[]; - onResolve: (id: number, response: ElicitationResponse) => void; -}; - -const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { - return ( - -
- - - When the server requests information from the user, requests will - appear here for response. - - -
-

Recent Requests

- {pendingRequests.map((request) => ( - - ))} - {pendingRequests.length === 0 && ( -

No pending requests

- )} -
-
-
- ); -}; - -export default ElicitationTab; diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx deleted file mode 100644 index ccfd6d928..000000000 --- a/client/src/components/OAuthCallback.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useRef } from "react"; -import { InspectorOAuthClientProvider } from "../lib/auth"; -import { SESSION_KEYS } from "../lib/constants"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { useToast } from "@/lib/hooks/useToast"; -import { - generateOAuthErrorDescription, - parseOAuthCallbackParams, -} from "@/utils/oauthUtils.ts"; - -interface OAuthCallbackProps { - onConnect: (serverUrl: string) => void; -} - -const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => { - const { toast } = useToast(); - const hasProcessedRef = useRef(false); - - useEffect(() => { - const handleCallback = async () => { - // Skip if we've already processed this callback - if (hasProcessedRef.current) { - return; - } - hasProcessedRef.current = true; - - const notifyError = (description: string) => - void toast({ - title: "OAuth Authorization Error", - description, - variant: "destructive", - }); - - const params = parseOAuthCallbackParams(window.location.search); - if (!params.successful) { - return notifyError(generateOAuthErrorDescription(params)); - } - - const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); - if (!serverUrl) { - return notifyError("Missing Server URL"); - } - - let result; - try { - // Create an auth provider with the current server URL - const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl); - - result = await auth(serverAuthProvider, { - serverUrl, - authorizationCode: params.code, - }); - } catch (error) { - console.error("OAuth callback error:", error); - return notifyError(`Unexpected error occurred: ${error}`); - } - - if (result !== "AUTHORIZED") { - return notifyError( - `Expected to be authorized after providing auth code, got: ${result}`, - ); - } - - // Finally, trigger auto-connect - toast({ - title: "Success", - description: "Successfully authenticated with OAuth", - variant: "default", - }); - onConnect(serverUrl); - }; - - handleCallback().finally(() => { - window.history.replaceState({}, document.title, "/"); - }); - }, [toast, onConnect]); - - return ( -
-

Processing OAuth callback...

-
- ); -}; - -export default OAuthCallback; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx deleted file mode 100644 index 95ccc0760..000000000 --- a/client/src/components/OAuthDebugCallback.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useEffect } from "react"; -import { SESSION_KEYS } from "../lib/constants"; -import { - generateOAuthErrorDescription, - parseOAuthCallbackParams, -} from "@/utils/oauthUtils.ts"; -import { AuthDebuggerState } from "@/lib/auth-types"; - -interface OAuthCallbackProps { - onConnect: ({ - authorizationCode, - errorMsg, - restoredState, - }: { - authorizationCode?: string; - errorMsg?: string; - restoredState?: AuthDebuggerState; - }) => void; -} - -const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { - useEffect(() => { - let isProcessed = false; - - const handleCallback = async () => { - // Skip if we've already processed this callback - if (isProcessed) { - return; - } - isProcessed = true; - - const params = parseOAuthCallbackParams(window.location.search); - if (!params.successful) { - const errorMsg = generateOAuthErrorDescription(params); - onConnect({ errorMsg }); - return; - } - - const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); - - // Try to restore the auth state - const storedState = sessionStorage.getItem( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - ); - let restoredState = null; - if (storedState) { - try { - restoredState = JSON.parse(storedState); - if (restoredState && typeof restoredState.resource === "string") { - restoredState.resource = new URL(restoredState.resource); - } - if ( - restoredState && - typeof restoredState.authorizationUrl === "string" - ) { - restoredState.authorizationUrl = new URL( - restoredState.authorizationUrl, - ); - } - // Clean up the stored state - sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE); - } catch (e) { - console.error("Failed to parse stored auth state:", e); - } - } - - // ServerURL isn't set, this can happen if we've opened the - // authentication request in a new tab, so we don't have the same - // session storage - if (!serverUrl) { - // If there's no server URL, we're likely in a new tab - // Just display the code for manual copying - return; - } - - if (!params.code) { - onConnect({ errorMsg: "Missing authorization code" }); - return; - } - - // Instead of storing in sessionStorage, pass the code directly - // to the auth state manager through onConnect, along with restored state - onConnect({ authorizationCode: params.code, restoredState }); - }; - - handleCallback().finally(() => { - // Only redirect if we have the URL set, otherwise assume this was - // in a new tab - if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { - window.history.replaceState({}, document.title, "/"); - } - }); - - return () => { - isProcessed = true; - }; - }, [onConnect]); - - const callbackParams = parseOAuthCallbackParams(window.location.search); - - return ( -
-
-

- Please copy this authorization code and return to the Auth Debugger: -

- - {callbackParams.successful && "code" in callbackParams - ? callbackParams.code - : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} - -

- Close this tab and paste the code in the OAuth flow to complete - authentication. -

-
-
- ); -}; - -export default OAuthDebugCallback; diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx deleted file mode 100644 index b05a48981..000000000 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ /dev/null @@ -1,797 +0,0 @@ -import { - render, - screen, - fireEvent, - waitFor, - act, -} from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { describe, it, beforeEach, jest } from "@jest/globals"; -import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger"; -import { TooltipProvider } from "../ui/tooltip"; -import { SESSION_KEYS } from "../../lib/constants"; - -const mockOAuthTokens = { - access_token: "test_access_token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "test_refresh_token", - scope: "test_scope", -}; - -const mockOAuthMetadata = { - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], -}; - -const mockOAuthClientInfo = { - client_id: "test_client_id", - client_secret: "test_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], -}; - -// Mock MCP SDK functions - must be before imports -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn(), - discoverAuthorizationServerMetadata: jest.fn(), - registerClient: jest.fn(), - startAuthorization: jest.fn(), - exchangeAuthorization: jest.fn(), - discoverOAuthProtectedResourceMetadata: jest.fn(), - selectResourceURL: jest.fn(), -})); - -// Import the functions to get their types -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - auth, - discoverOAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { EMPTY_DEBUGGER_STATE } from "../../lib/auth-types"; - -// Mock local auth module -jest.mock("@/lib/auth", () => ({ - DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)), - clear: jest.fn().mockImplementation(() => { - // Mock the real clear() behavior which removes items from sessionStorage - sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens"); - sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info"); - sessionStorage.removeItem( - "[https://example.com/mcp] mcp_server_metadata", - ); - }), - redirectUrl: "http://localhost:3000/oauth/callback/debug", - clientMetadata: { - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - }, - clientInformation: jest.fn().mockImplementation(async () => { - const serverUrl = "https://example.com/mcp"; - const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`; - const preregisteredData = sessionStorage.getItem(preregisteredKey); - if (preregisteredData) { - return JSON.parse(preregisteredData); - } - const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - const dynamicData = sessionStorage.getItem(dynamicKey); - if (dynamicData) { - return JSON.parse(dynamicData); - } - return undefined; - }), - saveClientInformation: jest.fn().mockImplementation((clientInfo) => { - const serverUrl = "https://example.com/mcp"; - const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - sessionStorage.setItem(key, JSON.stringify(clientInfo)); - }), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - saveServerMetadata: jest.fn(), - getServerMetadata: jest.fn(), - })), - discoverScopes: jest.fn().mockResolvedValue("read write" as never), -})); - -import { discoverScopes } from "../../lib/auth"; - -// Type the mocked functions properly -const mockDiscoverAuthorizationServerMetadata = - discoverAuthorizationServerMetadata as jest.MockedFunction< - typeof discoverAuthorizationServerMetadata - >; -const mockRegisterClient = registerClient as jest.MockedFunction< - typeof registerClient ->; -const mockStartAuthorization = startAuthorization as jest.MockedFunction< - typeof startAuthorization ->; -const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< - typeof exchangeAuthorization ->; -const mockAuth = auth as jest.MockedFunction; -const mockDiscoverOAuthProtectedResourceMetadata = - discoverOAuthProtectedResourceMetadata as jest.MockedFunction< - typeof discoverOAuthProtectedResourceMetadata - >; -const mockDiscoverScopes = discoverScopes as jest.MockedFunction< - typeof discoverScopes ->; - -const sessionStorageMock = { - getItem: jest.fn(), - setItem: jest.fn(), - removeItem: jest.fn(), - clear: jest.fn(), -}; -Object.defineProperty(window, "sessionStorage", { - value: sessionStorageMock, -}); - -describe("AuthDebugger", () => { - const defaultAuthState = EMPTY_DEBUGGER_STATE; - - const defaultProps = { - serverUrl: "https://example.com/mcp", - onBack: jest.fn(), - authState: defaultAuthState, - updateAuthState: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - sessionStorageMock.getItem.mockReturnValue(null); - - // Suppress console errors in tests to avoid JSDOM navigation noise - jest.spyOn(console, "error").mockImplementation(() => {}); - - // Set default mock behaviors with complete OAuth metadata - mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], - }); - mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( - new Error("No protected resource metadata found"), - ); - mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { - const authUrl = new URL("https://oauth.example.com/authorize"); - - if (options.scope) { - authUrl.searchParams.set("scope", options.scope); - } - - return { - authorizationUrl: authUrl, - codeVerifier: "test_verifier", - }; - }); - mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - const renderAuthDebugger = (props: Partial = {}) => { - const mergedProps = { - ...defaultProps, - ...props, - authState: { ...defaultAuthState, ...(props.authState || {}) }, - }; - return render( - - - , - ); - }; - - describe("Initial Rendering", () => { - it("should render the component with correct title", async () => { - await act(async () => { - renderAuthDebugger(); - }); - expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); - }); - - it("should call onBack when Back button is clicked", async () => { - const onBack = jest.fn(); - await act(async () => { - renderAuthDebugger({ onBack }); - }); - fireEvent.click(screen.getByText("Back to Connect")); - expect(onBack).toHaveBeenCalled(); - }); - }); - - describe("OAuth Flow", () => { - it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => { - await act(async () => { - renderAuthDebugger(); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); - }); - - it("should show error when OAuth flow is started without sseUrl", async () => { - const updateAuthState = jest.fn(); - await act(async () => { - renderAuthDebugger({ serverUrl: "", updateAuthState }); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - expect(updateAuthState).toHaveBeenCalledWith({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, - }); - }); - - it("should start quick OAuth flow and properly fetch and save metadata", async () => { - // Setup the auth mock - mockAuth.mockResolvedValue("AUTHORIZED"); - - const updateAuthState = jest.fn(); - await act(async () => { - renderAuthDebugger({ updateAuthState }); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); - }); - - // Should first discover and save OAuth metadata - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); - - // Check that updateAuthState was called with the right info message - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthStep: "authorization_code", - }), - ); - }); - - it("should show error when quick OAuth flow fails to discover metadata", async () => { - mockDiscoverAuthorizationServerMetadata.mockRejectedValue( - new Error("Metadata discovery failed"), - ); - - const updateAuthState = jest.fn(); - await act(async () => { - renderAuthDebugger({ updateAuthState }); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); - }); - - // Check that updateAuthState was called with an error message - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - statusMessage: { - type: "error", - message: expect.stringContaining("Failed to start OAuth flow"), - }, - }), - ); - }); - }); - - describe("Session Storage Integration", () => { - it("should load OAuth tokens from session storage", async () => { - // Mock the specific key for tokens with server URL - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); - - await act(async () => { - renderAuthDebugger({ - authState: { - ...defaultAuthState, - oauthTokens: mockOAuthTokens, - }, - }); - }); - - await waitFor(() => { - expect(screen.getByText(/Access Token:/)).toBeInTheDocument(); - }); - }); - - it("should handle errors loading OAuth tokens from session storage", async () => { - // Mock console to avoid cluttering test output - const originalError = console.error; - console.error = jest.fn(); - - // Mock getItem to return invalid JSON for tokens - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return "invalid json"; - } - return null; - }); - - await act(async () => { - renderAuthDebugger(); - }); - - // Component should still render despite the error - expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); - - // Restore console.error - console.error = originalError; - }); - }); - - describe("OAuth State Management", () => { - it("should clear OAuth state when Clear button is clicked", async () => { - const updateAuthState = jest.fn(); - // Mock the session storage to return tokens for the specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); - - await act(async () => { - renderAuthDebugger({ - authState: { - ...defaultAuthState, - oauthTokens: mockOAuthTokens, - }, - updateAuthState, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Clear OAuth State")); - }); - - expect(updateAuthState).toHaveBeenCalledWith({ - authServerUrl: null, - authorizationUrl: null, - isInitiatingAuth: false, - resourceMetadata: null, - resourceMetadataError: null, - resource: null, - oauthTokens: null, - oauthStep: "metadata_discovery", - latestError: null, - oauthClientInfo: null, - oauthMetadata: null, - authorizationCode: "", - validationError: null, - statusMessage: { - type: "success", - message: "OAuth tokens cleared successfully", - }, - }); - - // Verify session storage was cleared - expect(sessionStorageMock.removeItem).toHaveBeenCalled(); - }); - }); - - describe("OAuth Flow Steps", () => { - it("should handle OAuth flow step progression", async () => { - const updateAuthState = jest.fn(); - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, // Changed to false so button is enabled - oauthStep: "metadata_discovery", - }, - }); - }); - - // Verify metadata discovery step - expect(screen.getByText("Metadata Discovery")).toBeInTheDocument(); - - // Click Continue - this should trigger metadata discovery - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); - }); - - // Setup helper for OAuth authorization tests - const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => { - const updateAuthState = jest.fn(); - - // Mock the session storage to return metadata - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) { - return JSON.stringify(metadata); - } - if ( - key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}` - ) { - return JSON.stringify(mockOAuthClientInfo); - } - return null; - }); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "authorization_redirect", - oauthMetadata: metadata, - oauthClientInfo: mockOAuthClientInfo, - }, - }); - }); - - // Click Continue to trigger authorization - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - return updateAuthState; - }; - - it("should include scope in authorization URL when scopes_supported is present", async () => { - const metadataWithScopes = { - ...mockOAuthMetadata, - scopes_supported: ["read", "write", "admin"], - }; - - const updateAuthState = - await setupAuthorizationUrlTest(metadataWithScopes); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); - }); - - it("should include scope in authorization URL when scopes_supported is not present", async () => { - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); - }); - - it("should omit scope from authorization URL when discoverScopes returns undefined", async () => { - // Mock discoverScopes to return undefined (no scopes available) - mockDiscoverScopes.mockResolvedValueOnce(undefined); - - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.not.stringContaining("scope="), - }), - ); - }); - }); - }); - - describe("Client Registration behavior", () => { - it("uses preregistered (static) client information without calling DCR", async () => { - const preregClientInfo = { - client_id: "static_client_id", - client_secret: "static_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - }; - - // Return preregistered client info for the server-specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if ( - key === - `[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}` - ) { - return JSON.stringify(preregClientInfo); - } - return null; - }); - - const updateAuthState = jest.fn(); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, - }, - }); - }); - - // Proceed from client_registration → authorization_redirect - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - // Should NOT attempt dynamic client registration - expect(mockRegisterClient).not.toHaveBeenCalled(); - - // Should advance with the preregistered client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "static_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); - }); - - it("falls back to DCR when no static client information is available", async () => { - // No preregistered or dynamic client info present in session storage - sessionStorageMock.getItem.mockImplementation(() => null); - - // DCR returns a new client - mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo); - - const updateAuthState = jest.fn(); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, - }, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockRegisterClient).toHaveBeenCalledTimes(1); - - // Should save and advance with the DCR client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); - - // Verify the dynamically registered client info was persisted - expect(sessionStorage.setItem).toHaveBeenCalledWith( - `[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`, - expect.any(String), - ); - }); - }); - - describe("OAuth State Persistence", () => { - it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { - const updateAuthState = jest.fn(); - - // Setup mocks for OAuth flow - mockStartAuthorization.mockResolvedValue({ - authorizationUrl: new URL( - "https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug", - ), - codeVerifier: "test_verifier", - }); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Quick OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); - }); - - // Wait for the flow to reach the authorization step - await waitFor(() => { - expect(sessionStorage.setItem).toHaveBeenCalledWith( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - expect.stringContaining('"oauthStep":"authorization_code"'), - ); - }); - - // Verify the stored state includes all the accumulated data - const storedStateCall = ( - sessionStorage.setItem as jest.Mock - ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); - - expect(storedStateCall).toBeDefined(); - const storedState = JSON.parse(storedStateCall![1] as string); - - expect(storedState).toMatchObject({ - oauthStep: "authorization_code", - authorizationUrl: expect.stringMatching( - /^https:\/\/oauth\.example\.com\/authorize/, - ), - oauthMetadata: expect.objectContaining({ - token_endpoint: "https://oauth.example.com/token", - }), - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - }); - }); - }); - - describe("OAuth Protected Resource Metadata", () => { - it("should successfully fetch and display protected resource metadata", async () => { - const updateAuthState = jest.fn(); - const mockResourceMetadata = { - resource: "https://example.com/mcp", - authorization_servers: ["https://custom-auth.example.com"], - bearer_methods_supported: ["header", "body"], - resource_documentation: "https://example.com/mcp/docs", - resource_policy_uri: "https://example.com/mcp/policy", - }; - - // Mock successful metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( - mockResourceMetadata, - ); - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow to start the process - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Verify that the flow started with metadata discovery - expect(updateAuthState).toHaveBeenCalledWith({ - oauthStep: "metadata_discovery", - authorizationUrl: null, - statusMessage: null, - latestError: null, - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata to be fetched - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the state was updated with the resource metadata - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: mockResourceMetadata, - authServerUrl: new URL("https://custom-auth.example.com"), - oauthStep: "client_registration", - }), - ); - }); - }); - - it("should handle protected resource metadata fetch failure gracefully", async () => { - const updateAuthState = jest.fn(); - const mockError = new Error("Failed to fetch resource metadata"); - - // Mock failed metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError); - // But OAuth metadata should still work with the original URL - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata fetch to fail - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the flow continues despite the error - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadataError: mockError, - // Should use the original server URL as fallback - authServerUrl: new URL("https://example.com/"), - oauthStep: "client_registration", - }), - ); - }); - - // Verify that regular OAuth metadata discovery was still called - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); - }); - }); -}); diff --git a/client/src/components/__tests__/ElicitationTab.test.tsx b/client/src/components/__tests__/ElicitationTab.test.tsx deleted file mode 100644 index ca5194619..000000000 --- a/client/src/components/__tests__/ElicitationTab.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { Tabs } from "@/components/ui/tabs"; -import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab"; - -describe("Elicitation tab", () => { - const mockOnResolve = jest.fn(); - - const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) => - render( - - - , - ); - - it("should render 'No pending requests' when there are no pending requests", () => { - renderElicitationTab([]); - expect( - screen.getByText( - "When the server requests information from the user, requests will appear here for response.", - ), - ).toBeTruthy(); - expect(screen.findByText("No pending requests")).toBeTruthy(); - }); - - it("should render the correct number of requests", () => { - renderElicitationTab( - Array.from({ length: 3 }, (_, i) => ({ - id: i, - request: { - id: i, - message: `Please provide information ${i}`, - requestedSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "Your name", - }, - }, - required: ["name"], - }, - }, - })), - ); - expect(screen.getAllByTestId("elicitation-request").length).toBe(3); - }); -}); diff --git a/client/src/components/__tests__/samplingTab.test.tsx b/client/src/components/__tests__/samplingTab.test.tsx deleted file mode 100644 index 3e7212161..000000000 --- a/client/src/components/__tests__/samplingTab.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { Tabs } from "@/components/ui/tabs"; -import SamplingTab, { PendingRequest } from "../SamplingTab"; - -describe("Sampling tab", () => { - const mockOnApprove = jest.fn(); - const mockOnReject = jest.fn(); - - const renderSamplingTab = (pendingRequests: PendingRequest[]) => - render( - - - , - ); - - it("should render 'No pending requests' when there are no pending requests", () => { - renderSamplingTab([]); - expect( - screen.getByText( - "When the server requests LLM sampling, requests will appear here for approval.", - ), - ).toBeTruthy(); - expect(screen.findByText("No pending requests")).toBeTruthy(); - }); - - it("should render the correct number of requests", () => { - renderSamplingTab( - Array.from({ length: 5 }, (_, i) => ({ - id: i, - request: { - method: "sampling/createMessage", - params: { - messages: [ - { - role: "user", - content: { - type: "text", - text: "What files are in the current directory?", - }, - }, - ], - systemPrompt: "You are a helpful file system assistant.", - includeContext: "thisServer", - maxTokens: 100, - }, - }, - })), - ); - expect(screen.getAllByTestId("sampling-request").length).toBe(5); - }); -}); diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts deleted file mode 100644 index 329b7f027..000000000 --- a/client/src/lib/__tests__/auth.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { discoverScopes } from "../auth"; -import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; - -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - discoverAuthorizationServerMetadata: jest.fn(), -})); - -const mockDiscoverAuth = - discoverAuthorizationServerMetadata as jest.MockedFunction< - typeof discoverAuthorizationServerMetadata - >; - -const baseMetadata = { - issuer: "https://test.com", - authorization_endpoint: "https://test.com/authorize", - token_endpoint: "https://test.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], -}; - -describe("discoverScopes", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const testCases = [ - { - name: "returns joined scopes from OAuth metadata", - mockResolves: baseMetadata, - serverUrl: "https://example.com", - expected: "read write", - expectedCallUrl: "https://example.com/", - }, - { - name: "prefers resource metadata over OAuth metadata", - mockResolves: baseMetadata, - serverUrl: "https://example.com", - resourceMetadata: { - resource: "https://example.com", - scopes_supported: ["admin", "full"], - }, - expected: "admin full", - }, - { - name: "falls back to OAuth when resource has empty scopes", - mockResolves: baseMetadata, - serverUrl: "https://example.com", - resourceMetadata: { - resource: "https://example.com", - scopes_supported: [], - }, - expected: "read write", - }, - { - name: "normalizes URL with port and path", - mockResolves: baseMetadata, - serverUrl: "https://example.com:8080/some/path", - expected: "read write", - expectedCallUrl: "https://example.com:8080/", - }, - { - name: "normalizes URL with trailing slash", - mockResolves: baseMetadata, - serverUrl: "https://example.com/", - expected: "read write", - expectedCallUrl: "https://example.com/", - }, - { - name: "handles single scope", - mockResolves: { ...baseMetadata, scopes_supported: ["admin"] }, - serverUrl: "https://example.com", - expected: "admin", - }, - { - name: "prefers resource metadata even with fewer scopes", - mockResolves: { - ...baseMetadata, - scopes_supported: ["read", "write", "admin", "full"], - }, - serverUrl: "https://example.com", - resourceMetadata: { - resource: "https://example.com", - scopes_supported: ["read"], - }, - expected: "read", - }, - ]; - - const undefinedCases = [ - { - name: "returns undefined when OAuth discovery fails", - mockRejects: new Error("Discovery failed"), - serverUrl: "https://example.com", - }, - { - name: "returns undefined when OAuth has no scopes", - mockResolves: { ...baseMetadata, scopes_supported: [] }, - serverUrl: "https://example.com", - }, - { - name: "returns undefined when scopes_supported missing", - mockResolves: (() => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { scopes_supported, ...rest } = baseMetadata; - return rest; - })(), - serverUrl: "https://example.com", - }, - { - name: "returns undefined with resource metadata but OAuth fails", - mockRejects: new Error("No OAuth metadata"), - serverUrl: "https://example.com", - resourceMetadata: { - resource: "https://example.com", - scopes_supported: ["read", "write"], - }, - }, - ]; - - test.each(testCases)( - "$name", - async ({ - mockResolves, - serverUrl, - resourceMetadata, - expected, - expectedCallUrl, - }) => { - mockDiscoverAuth.mockResolvedValue(mockResolves); - - const result = await discoverScopes(serverUrl, resourceMetadata); - - expect(result).toBe(expected); - if (expectedCallUrl) { - expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl)); - } - }, - ); - - test.each(undefinedCases)( - "$name", - async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => { - if (mockRejects) { - mockDiscoverAuth.mockRejectedValue(mockRejects); - } else { - mockDiscoverAuth.mockResolvedValue(mockResolves); - } - - const result = await discoverScopes(serverUrl, resourceMetadata); - - expect(result).toBeUndefined(); - }, - ); -}); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts deleted file mode 100644 index aaa834286..000000000 --- a/client/src/lib/auth-types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - OAuthMetadata, - OAuthClientInformationFull, - OAuthClientInformation, - OAuthTokens, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; - -// OAuth flow steps -export type OAuthStep = - | "metadata_discovery" - | "client_registration" - | "authorization_redirect" - | "authorization_code" - | "token_request" - | "complete"; - -// Message types for inline feedback -export type MessageType = "success" | "error" | "info"; - -export interface StatusMessage { - type: MessageType; - message: string; -} - -// Single state interface for OAuth state -export interface AuthDebuggerState { - isInitiatingAuth: boolean; - oauthTokens: OAuthTokens | null; - oauthStep: OAuthStep; - resourceMetadata: OAuthProtectedResourceMetadata | null; - resourceMetadataError: Error | null; - resource: URL | null; - authServerUrl: URL | null; - oauthMetadata: OAuthMetadata | null; - oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; - authorizationUrl: URL | null; - authorizationCode: string; - latestError: Error | null; - statusMessage: StatusMessage | null; - validationError: string | null; -} - -export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { - isInitiatingAuth: false, - oauthTokens: null, - oauthStep: "metadata_discovery", - oauthMetadata: null, - resourceMetadata: null, - resourceMetadataError: null, - resource: null, - authServerUrl: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, -}; diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts deleted file mode 100644 index 879936104..000000000 --- a/client/src/lib/auth.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { - OAuthClientInformationSchema, - OAuthClientInformation, - OAuthTokens, - OAuthTokensSchema, - OAuthClientMetadata, - OAuthMetadata, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; -import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; -import { SESSION_KEYS, getServerSpecificKey } from "./constants"; -import { generateOAuthState } from "@/utils/oauthUtils"; -import { validateRedirectUrl } from "@/utils/urlValidation"; - -/** - * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes - * @param serverUrl - The MCP server URL - * @param resourceMetadata - Optional resource metadata containing preferred scopes - * @returns Promise resolving to space-separated scope string or undefined - */ -export const discoverScopes = async ( - serverUrl: string, - resourceMetadata?: OAuthProtectedResourceMetadata, -): Promise => { - try { - const metadata = await discoverAuthorizationServerMetadata( - new URL("/", serverUrl), - ); - - // Prefer resource metadata scopes, but fall back to OAuth metadata if empty - const resourceScopes = resourceMetadata?.scopes_supported; - const oauthScopes = metadata?.scopes_supported; - - const scopesSupported = - resourceScopes && resourceScopes.length > 0 - ? resourceScopes - : oauthScopes; - - return scopesSupported && scopesSupported.length > 0 - ? scopesSupported.join(" ") - : undefined; - } catch (error) { - console.debug("OAuth scope discovery failed:", error); - return undefined; - } -}; - -export const getClientInformationFromSessionStorage = async ({ - serverUrl, - isPreregistered, -}: { - serverUrl: string; - isPreregistered?: boolean; -}) => { - const key = getServerSpecificKey( - isPreregistered - ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION - : SESSION_KEYS.CLIENT_INFORMATION, - serverUrl, - ); - - const value = sessionStorage.getItem(key); - if (!value) { - return undefined; - } - - return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); -}; - -export const saveClientInformationToSessionStorage = ({ - serverUrl, - clientInformation, - isPreregistered, -}: { - serverUrl: string; - clientInformation: OAuthClientInformation; - isPreregistered?: boolean; -}) => { - const key = getServerSpecificKey( - isPreregistered - ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION - : SESSION_KEYS.CLIENT_INFORMATION, - serverUrl, - ); - sessionStorage.setItem(key, JSON.stringify(clientInformation)); -}; - -export const clearClientInformationFromSessionStorage = ({ - serverUrl, - isPreregistered, -}: { - serverUrl: string; - isPreregistered?: boolean; -}) => { - const key = getServerSpecificKey( - isPreregistered - ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION - : SESSION_KEYS.CLIENT_INFORMATION, - serverUrl, - ); - sessionStorage.removeItem(key); -}; - -export const getScopeFromSessionStorage = ( - serverUrl: string, -): string | undefined => { - const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); - const value = sessionStorage.getItem(key); - return value || undefined; -}; - -export const saveScopeToSessionStorage = ( - serverUrl: string, - scope: string | undefined, -) => { - const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); - if (scope) { - sessionStorage.setItem(key, scope); - } else { - sessionStorage.removeItem(key); - } -}; - -export const clearScopeFromSessionStorage = (serverUrl: string) => { - const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); - sessionStorage.removeItem(key); -}; - -export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(protected serverUrl: string) { - // Save the server URL to session storage - sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); - } - - get scope(): string | undefined { - return getScopeFromSessionStorage(this.serverUrl); - } - - get redirectUrl() { - return window.location.origin + "/oauth/callback"; - } - - get debugRedirectUrl() { - return window.location.origin + "/oauth/callback/debug"; - } - - get redirect_uris() { - // Normally register both redirect URIs to support both normal and debug flows - // In debug subclass, redirectUrl may be the same as debugRedirectUrl, so remove duplicates - // See: https://github.com/modelcontextprotocol/inspector/issues/825 - return [...new Set([this.redirectUrl, this.debugRedirectUrl])]; - } - - get clientMetadata(): OAuthClientMetadata { - const metadata: OAuthClientMetadata = { - redirect_uris: this.redirect_uris, - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - client_uri: "https://github.com/modelcontextprotocol/inspector", - }; - - // Only include scope if it's defined and non-empty - // Per OAuth spec, omit the scope field entirely if no scopes are requested - if (this.scope) { - metadata.scope = this.scope; - } - - return metadata; - } - - state(): string | Promise { - return generateOAuthState(); - } - - async clientInformation() { - // Try to get the preregistered client information from session storage first - const preregisteredClientInformation = - await getClientInformationFromSessionStorage({ - serverUrl: this.serverUrl, - isPreregistered: true, - }); - - // If no preregistered client information is found, get the dynamically registered client information - return ( - preregisteredClientInformation ?? - (await getClientInformationFromSessionStorage({ - serverUrl: this.serverUrl, - isPreregistered: false, - })) - ); - } - - saveClientInformation(clientInformation: OAuthClientInformation) { - // Save the dynamically registered client information to session storage - saveClientInformationToSessionStorage({ - serverUrl: this.serverUrl, - clientInformation, - isPreregistered: false, - }); - } - - async tokens() { - const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); - const tokens = sessionStorage.getItem(key); - if (!tokens) { - return undefined; - } - - return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); - } - - saveTokens(tokens: OAuthTokens) { - const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); - sessionStorage.setItem(key, JSON.stringify(tokens)); - } - - redirectToAuthorization(authorizationUrl: URL) { - // Validate the URL using the shared utility - validateRedirectUrl(authorizationUrl.href); - window.location.href = authorizationUrl.href; - } - - saveCodeVerifier(codeVerifier: string) { - const key = getServerSpecificKey( - SESSION_KEYS.CODE_VERIFIER, - this.serverUrl, - ); - sessionStorage.setItem(key, codeVerifier); - } - - codeVerifier() { - const key = getServerSpecificKey( - SESSION_KEYS.CODE_VERIFIER, - this.serverUrl, - ); - const verifier = sessionStorage.getItem(key); - if (!verifier) { - throw new Error("No code verifier saved for session"); - } - - return verifier; - } - - clear() { - clearClientInformationFromSessionStorage({ - serverUrl: this.serverUrl, - isPreregistered: false, - }); - sessionStorage.removeItem( - getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), - ); - sessionStorage.removeItem( - getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl), - ); - } -} - -// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to -// display in debug UI. -export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { - get redirectUrl(): string { - // We can use the debug redirect URL here because it was already registered - // in the parent class's clientMetadata along with the normal redirect URL - return this.debugRedirectUrl; - } - - saveServerMetadata(metadata: OAuthMetadata) { - const key = getServerSpecificKey( - SESSION_KEYS.SERVER_METADATA, - this.serverUrl, - ); - sessionStorage.setItem(key, JSON.stringify(metadata)); - } - - getServerMetadata(): OAuthMetadata | null { - const key = getServerSpecificKey( - SESSION_KEYS.SERVER_METADATA, - this.serverUrl, - ); - const metadata = sessionStorage.getItem(key); - if (!metadata) { - return null; - } - return JSON.parse(metadata); - } - - clear() { - super.clear(); - sessionStorage.removeItem( - getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), - ); - } -} diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx deleted file mode 100644 index 4907a085b..000000000 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ /dev/null @@ -1,1673 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { useConnection } from "../useConnection"; -import { z } from "zod/v3"; -import { - ClientRequest, - CreateTaskResultSchema, - JSONRPCMessage, -} from "@modelcontextprotocol/sdk/types.js"; -import type { - AnySchema, - SchemaOutput, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants"; -import { - SSEClientTransportOptions, - SseError, -} from "@modelcontextprotocol/sdk/client/sse.js"; -import { - ElicitResult, - ElicitRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { discoverScopes } from "../../auth"; -import { CustomHeaders } from "../../types/customHeaders"; - -// Mock fetch -global.fetch = jest.fn().mockResolvedValue({ - json: () => Promise.resolve({ status: "ok" }), - headers: { - get: jest.fn().mockReturnValue(null), - }, -}); - -// Mock the SDK dependencies -const mockRequest = jest.fn().mockResolvedValue({ test: "response" }); -const mockClient = { - request: mockRequest, - notification: jest.fn(), - connect: jest.fn().mockResolvedValue(undefined), - close: jest.fn(), - getServerCapabilities: jest.fn(), - getServerVersion: jest.fn(), - getInstructions: jest.fn(), - setNotificationHandler: jest.fn(), - setRequestHandler: jest.fn(), -}; - -// Mock transport instances -const mockSSETransport: { - start: jest.Mock; - url: URL | undefined; - options: SSEClientTransportOptions | undefined; - onmessage?: (message: JSONRPCMessage) => void; -} = { - start: jest.fn(), - url: undefined, - options: undefined, - onmessage: undefined, -}; - -const mockStreamableHTTPTransport: { - start: jest.Mock; - url: URL | undefined; - options: SSEClientTransportOptions | undefined; -} = { - start: jest.fn(), - url: undefined, - options: undefined, -}; - -jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ - Client: jest.fn().mockImplementation(() => mockClient), -})); - -jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => { - // Minimal mock class that supports instanceof checks - class SseError extends Error { - code: number; - event: ErrorEvent; - constructor(code: number, message: string, event: ErrorEvent) { - super(message); - this.code = code; - this.event = event; - } - } - - return { - SSEClientTransport: jest.fn((url, options) => { - mockSSETransport.url = url; - mockSSETransport.options = options; - return mockSSETransport; - }), - SseError, - }; -}); - -jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: jest.fn((url, options) => { - mockStreamableHTTPTransport.url = url; - mockStreamableHTTPTransport.options = options; - return mockStreamableHTTPTransport; - }), -})); - -jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - auth: jest.fn().mockResolvedValue("AUTHORIZED"), -})); - -// Mock the toast hook -const mockToast = jest.fn(); -jest.mock("@/lib/hooks/useToast", () => ({ - useToast: () => ({ - toast: mockToast, - }), -})); - -// Mock the auth provider -jest.mock("../../auth", () => ({ - InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ - tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }), - redirectUrl: "http://localhost:3000/oauth/callback", - })), - clearClientInformationFromSessionStorage: jest.fn(), - saveClientInformationToSessionStorage: jest.fn(), - saveScopeToSessionStorage: jest.fn(), - clearScopeFromSessionStorage: jest.fn(), - discoverScopes: jest.fn(), -})); - -const mockAuth = auth as jest.MockedFunction; -const mockDiscoverScopes = discoverScopes as jest.MockedFunction< - typeof discoverScopes ->; - -describe("useConnection", () => { - const defaultProps: Parameters[0] = { - transportType: "sse" as const, - command: "", - args: "", - sseUrl: "http://localhost:8080", - env: {}, - config: DEFAULT_INSPECTOR_CONFIG, - }; - - describe("Request Configuration", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("uses the default config values in makeRequest", async () => { - const { result } = renderHook(() => useConnection(defaultProps)); - - // Connect the client - await act(async () => { - await result.current.connect(); - }); - - // Wait for state update - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - const mockRequest: ClientRequest = { - method: "ping", - params: {}, - }; - - const mockSchema = z.object({ - test: z.string(), - }); - - const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema; - - await act(async () => { - await result.current.makeRequest(mockRequest, mockSchemaAny); - }); - - expect(mockClient.request).toHaveBeenCalledWith( - mockRequest, - mockSchema, - expect.objectContaining({ - timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value, - maxTotalTimeout: - DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value, - resetTimeoutOnProgress: - DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS - .value, - }), - ); - }); - - test("overrides the default config values when passed in options in makeRequest", async () => { - const { result } = renderHook(() => useConnection(defaultProps)); - - // Connect the client - await act(async () => { - await result.current.connect(); - }); - - // Wait for state update - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - const mockRequest: ClientRequest = { - method: "ping", - params: {}, - }; - - const mockSchema = z.object({ - test: z.string(), - }); - - const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema; - - await act(async () => { - await result.current.makeRequest(mockRequest, mockSchemaAny, { - timeout: 1000, - maxTotalTimeout: 2000, - resetTimeoutOnProgress: false, - }); - }); - - expect(mockClient.request).toHaveBeenCalledWith( - mockRequest, - mockSchema, - expect.objectContaining({ - timeout: 1000, - maxTotalTimeout: 2000, - resetTimeoutOnProgress: false, - }), - ); - }); - }); - - describe("Receiver-side Tasks (task-augmented incoming requests)", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("declares tasks.requests.sampling.createMessage when onPendingRequest is provided", async () => { - const Client = jest.requireMock( - "@modelcontextprotocol/sdk/client/index.js", - ).Client; - - const propsWithPending = { - ...defaultProps, - onPendingRequest: jest.fn(), - }; - - const { result } = renderHook(() => useConnection(propsWithPending)); - - await act(async () => { - await result.current.connect(); - }); - - expect(Client).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - capabilities: expect.objectContaining({ - tasks: expect.objectContaining({ - requests: expect.objectContaining({ - sampling: expect.objectContaining({ - createMessage: {}, - }), - }), - }), - }), - }), - ); - }); - - test("task-augmented sampling/createMessage returns { task } and tasks/result blocks until resolved", async () => { - let pendingResolve: ((value: unknown) => void) | undefined; - let pendingReject: ((reason?: unknown) => void) | undefined; - - const mockOnPendingRequest = jest.fn((_request, resolve, reject) => { - pendingResolve = resolve; - pendingReject = reject; - }); - - const propsWithPending = { - ...defaultProps, - onPendingRequest: mockOnPendingRequest, - }; - - const { result } = renderHook(() => useConnection(propsWithPending)); - - await act(async () => { - await result.current.connect(); - }); - - const samplingRequest = { - method: "sampling/createMessage", - params: { - task: { ttl: 0 }, - messages: [ - { - role: "user", - content: { type: "text", text: "hello" }, - }, - ], - maxTokens: 1, - }, - }; - - // Locate the sampling/createMessage handler - const samplingHandlerCall = mockClient.setRequestHandler.mock.calls.find( - (call) => { - try { - const schema = call[0]; - const parseResult = - schema.safeParse && schema.safeParse(samplingRequest); - return parseResult?.success; - } catch { - return false; - } - }, - ); - - expect(samplingHandlerCall).toBeDefined(); - const [, samplingHandler] = samplingHandlerCall; - - // Invoke handler; should return a CreateTaskResult immediately - let createTaskResult: SchemaOutput; - await act(async () => { - createTaskResult = await samplingHandler(samplingRequest); - }); - - expect(createTaskResult).toHaveProperty("task"); - expect(createTaskResult.task).toEqual( - expect.objectContaining({ - taskId: expect.any(String), - status: "input_required", - ttl: 0, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - }), - ); - - expect(mockOnPendingRequest).toHaveBeenCalledTimes(1); - expect(pendingResolve).toBeDefined(); - expect(pendingReject).toBeDefined(); - - const taskId = createTaskResult.task.taskId as string; - - // Locate tasks/get and tasks/result handlers - const taskGetRequest = { method: "tasks/get", params: { taskId } }; - const taskResultRequest = { method: "tasks/result", params: { taskId } }; - - const taskGetHandlerCall = mockClient.setRequestHandler.mock.calls.find( - (call) => { - try { - const schema = call[0]; - const parseResult = - schema.safeParse && schema.safeParse(taskGetRequest); - return parseResult?.success; - } catch { - return false; - } - }, - ); - const taskResultHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const parseResult = - schema.safeParse && schema.safeParse(taskResultRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - expect(taskGetHandlerCall).toBeDefined(); - expect(taskResultHandlerCall).toBeDefined(); - - const [, taskGetHandler] = taskGetHandlerCall; - const [, taskResultHandler] = taskResultHandlerCall; - - // Verify tasks/get sees the in-progress task - const getBefore = await taskGetHandler(taskGetRequest); - expect(getBefore.status).toBe("input_required"); - - // tasks/result should block until user flow resolves - const payloadPromise = taskResultHandler(taskResultRequest); - const race = await Promise.race([ - payloadPromise.then(() => "resolved"), - new Promise((r) => setTimeout(() => r("timeout"), 10)), - ]); - expect(race).toBe("timeout"); - - const mockPayload = { - model: "test-model", - role: "assistant", - content: { type: "text", text: "ok" }, - }; - - await act(async () => { - pendingResolve!(mockPayload); - // Let the background updater run - await new Promise((r) => setTimeout(r, 0)); - }); - - await expect(payloadPromise).resolves.toEqual(mockPayload); - - const getAfter = await taskGetHandler(taskGetRequest); - expect(getAfter.status).toBe("completed"); - }); - - test("task-augmented elicitation/create returns { task } immediately", async () => { - const mockOnElicitationRequest = jest.fn(); - const propsWithElicitation = { - ...defaultProps, - onElicitationRequest: mockOnElicitationRequest, - }; - - const { result } = renderHook(() => useConnection(propsWithElicitation)); - - await act(async () => { - await result.current.connect(); - }); - - const elicitationRequest = { - method: "elicitation/create", - params: { - task: { ttl: 0 }, - message: "Please provide your name", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }, - }, - }; - - const elicitRequestHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const parseResult = - schema.safeParse && schema.safeParse(elicitationRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - expect(elicitRequestHandlerCall).toBeDefined(); - const [, handler] = elicitRequestHandlerCall; - - mockOnElicitationRequest.mockImplementation((_request, resolve) => { - resolve({ action: "accept", content: { name: "test" } }); - }); - - const resultValue = await handler(elicitationRequest); - - expect(resultValue).toHaveProperty("task"); - expect(resultValue.task).toEqual( - expect.objectContaining({ - taskId: expect.any(String), - status: "input_required", - ttl: 0, - }), - ); - }); - }); - - test("throws error when mcpClient is not connected", async () => { - const { result } = renderHook(() => { - const { makeRequest } = useConnection(defaultProps) as unknown as { - makeRequest: ( - request: ClientRequest, - schema: AnySchema, - ) => Promise; - }; - return { makeRequest }; - }); - - const mockRequest: ClientRequest = { - method: "ping", - params: {}, - }; - - const mockSchema = z.object({ - test: z.string(), - }); - - const mockSchemaAny: AnySchema = mockSchema as unknown as AnySchema; - - await expect( - result.current.makeRequest(mockRequest, mockSchemaAny), - ).rejects.toThrow("MCP client not connected"); - }); - - describe("Elicitation Support", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("declares elicitation capability during client initialization", async () => { - const Client = jest.requireMock( - "@modelcontextprotocol/sdk/client/index.js", - ).Client; - - const { result } = renderHook(() => useConnection(defaultProps)); - - await act(async () => { - await result.current.connect(); - }); - - expect(Client).toHaveBeenCalledWith( - expect.objectContaining({ - name: CLIENT_IDENTITY.name, - version: CLIENT_IDENTITY.version, - }), - expect.objectContaining({ - capabilities: expect.objectContaining({ - elicitation: {}, - }), - }), - ); - }); - - test("sets up elicitation request handler when onElicitationRequest is provided", async () => { - const mockOnElicitationRequest = jest.fn(); - const propsWithElicitation = { - ...defaultProps, - onElicitationRequest: mockOnElicitationRequest, - }; - - const { result } = renderHook(() => useConnection(propsWithElicitation)); - - await act(async () => { - await result.current.connect(); - }); - - const elicitRequestHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const testRequest = { - method: "elicitation/create", - params: { - message: "test message", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }; - const parseResult = - schema.safeParse && schema.safeParse(testRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - expect(elicitRequestHandlerCall).toBeDefined(); - expect(mockClient.setRequestHandler).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Function), - ); - }); - - test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => { - const { result } = renderHook(() => useConnection(defaultProps)); - - await act(async () => { - await result.current.connect(); - }); - - const elicitRequestHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const testRequest = { - method: "elicitation/create", - params: { - message: "test message", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }; - const parseResult = - schema.safeParse && schema.safeParse(testRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - expect(elicitRequestHandlerCall).toBeUndefined(); - }); - - test("elicitation request handler calls onElicitationRequest callback", async () => { - const mockOnElicitationRequest = jest.fn(); - const propsWithElicitation = { - ...defaultProps, - onElicitationRequest: mockOnElicitationRequest, - }; - - const { result } = renderHook(() => useConnection(propsWithElicitation)); - - await act(async () => { - await result.current.connect(); - }); - - const elicitRequestHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const testRequest = { - method: "elicitation/create", - params: { - message: "test message", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }; - const parseResult = - schema.safeParse && schema.safeParse(testRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - expect(elicitRequestHandlerCall).toBeDefined(); - const [, handler] = elicitRequestHandlerCall; - - const mockElicitationRequest: ElicitRequest = { - method: "elicitation/create", - params: { - message: "Please provide your name", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }, - }, - }; - - mockOnElicitationRequest.mockImplementation((_request, resolve) => { - resolve({ action: "accept", content: { name: "test" } }); - }); - - await act(async () => { - await handler(mockElicitationRequest); - }); - - expect(mockOnElicitationRequest).toHaveBeenCalledWith( - mockElicitationRequest, - expect.any(Function), - ); - }); - - test("elicitation request handler returns a promise that resolves with the callback result", async () => { - const mockOnElicitationRequest = jest.fn(); - const propsWithElicitation = { - ...defaultProps, - onElicitationRequest: mockOnElicitationRequest, - }; - - const { result } = renderHook(() => useConnection(propsWithElicitation)); - - await act(async () => { - await result.current.connect(); - }); - - const elicitRequestHandlerCall = - mockClient.setRequestHandler.mock.calls.find((call) => { - try { - const schema = call[0]; - const testRequest = { - method: "elicitation/create", - params: { - message: "test message", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }; - const parseResult = - schema.safeParse && schema.safeParse(testRequest); - return parseResult?.success; - } catch { - return false; - } - }); - - const [, handler] = elicitRequestHandlerCall; - - const mockElicitationRequest: ElicitRequest = { - method: "elicitation/create", - params: { - message: "Please provide your name", - requestedSchema: { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }, - }, - }; - - const mockResponse: ElicitResult = { - action: "accept", - content: { name: "John Doe" }, - }; - - mockOnElicitationRequest.mockImplementation((_request, resolve) => { - resolve(mockResponse); - }); - - let handlerResult; - await act(async () => { - handlerResult = await handler(mockElicitationRequest); - }); - - expect(handlerResult).toEqual(mockResponse); - }); - }); - - describe("Ref Resolution", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("resolves $ref references in requestedSchema properties before validation", async () => { - const mockProtocolOnMessage = jest.fn(); - - mockSSETransport.onmessage = mockProtocolOnMessage; - - const { result } = renderHook(() => useConnection(defaultProps)); - - await act(async () => { - await result.current.connect(); - }); - - const mockRequestWithRef: JSONRPCMessage = { - jsonrpc: "2.0", - id: 1, - method: "elicitation/create", - params: { - message: "Please provide your information", - requestedSchema: { - type: "object", - properties: { - source: { - type: "string", - minLength: 1, - title: "A Connectable Node", - }, - target: { - $ref: "#/properties/source", - }, - }, - }, - }, - }; - - await act(async () => { - mockSSETransport.onmessage!(mockRequestWithRef); - }); - - expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1); - - const message = mockProtocolOnMessage.mock.calls[0][0]; - expect(message.params.requestedSchema.properties.target).toEqual({ - type: "string", - minLength: 1, - title: "A Connectable Node", - }); - }); - - test("resolves $ref references to $defs in requestedSchema", async () => { - const mockProtocolOnMessage = jest.fn(); - - mockSSETransport.onmessage = mockProtocolOnMessage; - - const { result } = renderHook(() => useConnection(defaultProps)); - - await act(async () => { - await result.current.connect(); - }); - - const mockRequestWithDefs: JSONRPCMessage = { - jsonrpc: "2.0", - id: 1, - method: "elicitation/create", - params: { - message: "Please provide your information", - requestedSchema: { - type: "object", - properties: { - user: { - $ref: "#/$defs/UserInput", - }, - }, - $defs: { - UserInput: { - type: "object", - properties: { - name: { - type: "string", - title: "Name", - }, - age: { - type: "integer", - title: "Age", - minimum: 0, - }, - }, - required: ["name"], - }, - }, - }, - }, - }; - - await act(async () => { - mockSSETransport.onmessage!(mockRequestWithDefs); - }); - - expect(mockProtocolOnMessage).toHaveBeenCalledTimes(1); - - const message = mockProtocolOnMessage.mock.calls[0][0]; - // The $ref should be resolved to the actual UserInput definition - expect(message.params.requestedSchema.properties.user).toEqual({ - type: "object", - properties: { - name: { - type: "string", - title: "Name", - }, - age: { - type: "integer", - title: "Age", - minimum: 0, - }, - }, - required: ["name"], - }); - }); - }); - - describe("URL Port Handling", () => { - const SSEClientTransport = jest.requireMock( - "@modelcontextprotocol/sdk/client/sse.js", - ).SSEClientTransport; - const StreamableHTTPClientTransport = jest.requireMock( - "@modelcontextprotocol/sdk/client/streamableHttp.js", - ).StreamableHTTPClientTransport; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - test("preserves HTTPS port number when connecting", async () => { - const props = { - ...defaultProps, - sseUrl: "https://example.com:8443/api", - transportType: "sse" as const, - }; - - const { result } = renderHook(() => useConnection(props)); - - await act(async () => { - await result.current.connect(); - }); - - const call = SSEClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain( - "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", - ); - }); - - test("preserves HTTP port number when connecting", async () => { - const props = { - ...defaultProps, - sseUrl: "http://localhost:3000/api", - transportType: "sse" as const, - }; - - const { result } = renderHook(() => useConnection(props)); - - await act(async () => { - await result.current.connect(); - }); - - const call = SSEClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain( - "url=http%3A%2F%2Flocalhost%3A3000%2Fapi", - ); - }); - - test("uses default port for HTTPS when not specified", async () => { - const props = { - ...defaultProps, - sseUrl: "https://example.com/api", - transportType: "sse" as const, - }; - - const { result } = renderHook(() => useConnection(props)); - - await act(async () => { - await result.current.connect(); - }); - - const call = SSEClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi"); - expect(call.toString()).not.toContain("%3A443"); - }); - - test("preserves port number in streamable-http transport", async () => { - const props = { - ...defaultProps, - sseUrl: "https://example.com:8443/api", - transportType: "streamable-http" as const, - }; - - const { result } = renderHook(() => useConnection(props)); - - await act(async () => { - await result.current.connect(); - }); - - const call = StreamableHTTPClientTransport.mock.calls[0][0]; - expect(call.toString()).toContain( - "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", - ); - }); - }); - - describe("Proxy Authentication Headers", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset the mock transport objects - mockSSETransport.url = undefined; - mockSSETransport.options = undefined; - mockStreamableHTTPTransport.url = undefined; - mockStreamableHTTPTransport.options = undefined; - }); - - test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => { - const propsWithProxyAuth = { - ...defaultProps, - connectionType: "proxy" as const, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }, - }; - - const { result } = renderHook(() => useConnection(propsWithProxyAuth)); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the transport was created with the correct headers - expect(mockSSETransport.options).toBeDefined(); - expect(mockSSETransport.options?.requestInit).toBeDefined(); - - expect(mockSSETransport.options?.requestInit?.headers).toHaveProperty( - "X-MCP-Proxy-Auth", - "Bearer test-proxy-token", - ); - expect(mockSSETransport?.options?.eventSourceInit?.fetch).toBeDefined(); - - // Verify the fetch function includes the proxy auth header - const mockFetch = mockSSETransport.options?.eventSourceInit?.fetch; - const testUrl = "http://test.com"; - await mockFetch?.(testUrl, { - headers: { - Accept: "text/event-stream", - }, - cache: "no-store", - mode: "cors", - signal: new AbortController().signal, - redirect: "follow", - credentials: "include", - }); - - expect(global.fetch).toHaveBeenCalledTimes(2); - expect( - (global.fetch as jest.Mock).mock.calls[0][1].headers, - ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); - expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(testUrl); - expect( - (global.fetch as jest.Mock).mock.calls[1][1].headers, - ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); - }); - - test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => { - const propsWithProxyAuth = { - ...defaultProps, - connectionType: "direct" as const, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }, - }; - - const { result } = renderHook(() => useConnection(propsWithProxyAuth)); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the transport was created with the correct headers - expect(mockSSETransport.options).toBeDefined(); - expect(mockSSETransport.options?.requestInit).toBeDefined(); - - // Verify that X-MCP-Proxy-Auth header is NOT present for direct connections - expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty( - "X-MCP-Proxy-Auth", - ); - expect(mockSSETransport?.options?.fetch).toBeDefined(); - - // Verify the fetch function does NOT include the proxy auth header - const mockFetch = mockSSETransport.options?.fetch; - const testUrl = "http://test.com"; - await mockFetch?.(testUrl, { - headers: { - Accept: "text/event-stream", - }, - cache: "no-store", - mode: "cors", - signal: new AbortController().signal, - redirect: "follow", - credentials: "include", - }); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl); - expect( - (global.fetch as jest.Mock).mock.calls[0][1].headers, - ).not.toHaveProperty("X-MCP-Proxy-Auth"); - }); - - test("does NOT send Authorization header for proxy auth", async () => { - const propsWithProxyAuth = { - ...defaultProps, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - proxyAuthToken: "test-proxy-token", - }, - }; - - const { result } = renderHook(() => useConnection(propsWithProxyAuth)); - - await act(async () => { - await result.current.connect(); - }); - - // Check that Authorization header is NOT used for proxy auth - expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty( - "Authorization", - "Bearer test-proxy-token", - ); - }); - - test("preserves server Authorization header when proxy auth is configured", async () => { - const customHeaders: CustomHeaders = [ - { - name: "Authorization", - value: "Bearer server-auth-token", - enabled: true, - }, - ]; - - const propsWithBothAuth = { - ...defaultProps, - customHeaders, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }, - }; - - const { result } = renderHook(() => useConnection(propsWithBothAuth)); - - await act(async () => { - await result.current.connect(); - }); - - // Check that both headers are present and distinct - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty( - "Authorization", - "Bearer server-auth-token", - ); - expect(headers).toHaveProperty( - "X-MCP-Proxy-Auth", - "Bearer test-proxy-token", - ); - }); - - test("sends X-MCP-Proxy-Auth in health check requests", async () => { - const fetchMock = global.fetch as jest.Mock; - fetchMock.mockClear(); - - const propsWithProxyAuth = { - ...defaultProps, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }, - }; - - const { result } = renderHook(() => useConnection(propsWithProxyAuth)); - - await act(async () => { - await result.current.connect(); - }); - - // Find the health check call - const healthCheckCall = fetchMock.mock.calls.find( - (call) => call[0].pathname === "/health", - ); - - expect(healthCheckCall).toBeDefined(); - expect(healthCheckCall[1].headers).toHaveProperty( - "X-MCP-Proxy-Auth", - "Bearer test-proxy-token", - ); - }); - - test("works correctly with streamable-http transport", async () => { - const propsWithStreamableHttp = { - ...defaultProps, - transportType: "streamable-http" as const, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-proxy-token", - }, - }, - }; - - const { result } = renderHook(() => - useConnection(propsWithStreamableHttp), - ); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the streamable HTTP transport was created with the correct headers - expect(mockStreamableHTTPTransport.options).toBeDefined(); - expect( - mockStreamableHTTPTransport.options?.requestInit?.headers, - ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); - }); - }); - - describe("Custom Headers", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset the mock transport objects - mockSSETransport.url = undefined; - mockSSETransport.options = undefined; - mockStreamableHTTPTransport.url = undefined; - mockStreamableHTTPTransport.options = undefined; - }); - - test("sends multiple custom headers correctly", async () => { - const customHeaders: CustomHeaders = [ - { name: "Authorization", value: "Bearer token123", enabled: true }, - { name: "X-Tenant-ID", value: "acme-inc", enabled: true }, - { name: "X-Environment", value: "staging", enabled: true }, - ]; - - const propsWithCustomHeaders = { - ...defaultProps, - customHeaders, - }; - - const { result } = renderHook(() => - useConnection(propsWithCustomHeaders), - ); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the transport was created with the correct headers - expect(mockSSETransport.options).toBeDefined(); - expect(mockSSETransport.options?.requestInit?.headers).toBeDefined(); - - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty("Authorization", "Bearer token123"); - expect(headers).toHaveProperty("X-Tenant-ID", "acme-inc"); - expect(headers).toHaveProperty("X-Environment", "staging"); - expect(headers).toHaveProperty( - "x-custom-auth-headers", - JSON.stringify(["X-Tenant-ID", "X-Environment"]), - ); - }); - - test("ignores disabled custom headers", async () => { - const customHeaders: CustomHeaders = [ - { name: "Authorization", value: "Bearer token123", enabled: true }, - { name: "X-Disabled", value: "should-not-appear", enabled: false }, - { name: "X-Enabled", value: "should-appear", enabled: true }, - ]; - - const propsWithCustomHeaders = { - ...defaultProps, - customHeaders, - }; - - const { result } = renderHook(() => - useConnection(propsWithCustomHeaders), - ); - - await act(async () => { - await result.current.connect(); - }); - - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty("Authorization", "Bearer token123"); - expect(headers).toHaveProperty("X-Enabled", "should-appear"); - expect(headers).not.toHaveProperty("X-Disabled"); - }); - - test("handles migrated legacy auth via custom headers", async () => { - // Simulate what App.tsx would do - migrate legacy auth to custom headers - const customHeaders: CustomHeaders = [ - { name: "X-Custom-Auth", value: "legacy-token", enabled: true }, - ]; - - const propsWithMigratedAuth = { - ...defaultProps, - customHeaders, - }; - - const { result } = renderHook(() => useConnection(propsWithMigratedAuth)); - - await act(async () => { - await result.current.connect(); - }); - - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty("X-Custom-Auth", "legacy-token"); - expect(headers).toHaveProperty( - "x-custom-auth-headers", - JSON.stringify(["X-Custom-Auth"]), - ); - }); - - test("uses OAuth token when no custom headers or legacy auth provided", async () => { - const propsWithoutAuth = { - ...defaultProps, - }; - - const { result } = renderHook(() => useConnection(propsWithoutAuth)); - - await act(async () => { - await result.current.connect(); - }); - - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty("Authorization", "Bearer mock-token"); - }); - - test("warns of enabled empty Bearer token", async () => { - // This test prevents regression of the bug where default "Bearer " header - // prevented OAuth token injection, causing infinite auth loops - const customHeaders: CustomHeaders = [ - { - name: "Authorization", - value: "Bearer ", // Empty Bearer token placeholder - enabled: true, // enabled - }, - ]; - - const propsWithEmptyBearer = { - ...defaultProps, - customHeaders, - }; - - const { result } = renderHook(() => useConnection(propsWithEmptyBearer)); - - await act(async () => { - await result.current.connect(); - }); - - const headers = mockSSETransport.options?.requestInit?.headers; - - expect(headers).toHaveProperty("Authorization", "Bearer"); - // Should not have the x-custom-auth-headers since Authorization is standard - expect(headers).not.toHaveProperty("x-custom-auth-headers"); - - // Should show toast notification for empty Authorization header - expect(mockToast).toHaveBeenCalledWith({ - title: "Invalid Authorization Header", - description: expect.any(String), - variant: "destructive", - }); - }); - - test("prioritizes custom headers over legacy auth", async () => { - const customHeaders: CustomHeaders = [ - { name: "Authorization", value: "Bearer custom-token", enabled: true }, - ]; - - const propsWithBothAuth = { - ...defaultProps, - customHeaders, - bearerToken: "legacy-token", - headerName: "Authorization", - }; - - const { result } = renderHook(() => useConnection(propsWithBothAuth)); - - await act(async () => { - await result.current.connect(); - }); - - const headers = mockSSETransport.options?.requestInit?.headers; - expect(headers).toHaveProperty("Authorization", "Bearer custom-token"); - }); - }); - - describe("Connection URL Verification", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset the mock transport objects - mockSSETransport.url = undefined; - mockSSETransport.options = undefined; - mockStreamableHTTPTransport.url = undefined; - mockStreamableHTTPTransport.options = undefined; - }); - - test("uses server URL directly when connectionType is 'direct'", async () => { - const directProps = { - ...defaultProps, - connectionType: "direct" as const, - }; - - const { result } = renderHook(() => useConnection(directProps)); - - await act(async () => { - await result.current.connect(); - }); - - // Verify the transport was created with the direct server URL - expect(mockSSETransport.url).toBeDefined(); - expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/"); - }); - - test("uses proxy server URL when connectionType is 'proxy'", async () => { - const proxyProps = { - ...defaultProps, - connectionType: "proxy" as const, - }; - - const { result } = renderHook(() => useConnection(proxyProps)); - - await act(async () => { - await result.current.connect(); - }); - - // Verify the transport was created with a proxy server URL - expect(mockSSETransport.url).toBeDefined(); - expect(mockSSETransport.url?.pathname).toBe("/sse"); - expect(mockSSETransport.url?.searchParams.get("url")).toBe( - "http://localhost:8080", - ); - expect(mockSSETransport.url?.searchParams.get("transportType")).toBe( - "sse", - ); - }); - }); - - describe("OAuth Error Handling with Scope Discovery", () => { - beforeEach(() => { - jest.clearAllMocks(); - mockAuth.mockResolvedValue("AUTHORIZED"); - mockDiscoverScopes.mockResolvedValue(undefined); - }); - - const setup401Error = () => { - const mockErrorEvent = new ErrorEvent("error", { - message: "Mock error event", - }); - mockClient.connect.mockRejectedValueOnce( - new SseError(401, "Unauthorized", mockErrorEvent), - ); - }; - - const attemptConnection = async (props = defaultProps) => { - const { result } = renderHook(() => useConnection(props)); - await act(async () => { - try { - await result.current.connect(); - } catch { - // Expected error from auth handling - } - }); - }; - - const testCases = [ - [ - "discovers and includes scopes in auth call", - { - discoveredScope: "read write admin", - oauthScope: undefined, - expectScopeCall: true, - expectedAuthScope: "read write admin", - authResult: "AUTHORIZED", - }, - ], - [ - "handles scope discovery failure gracefully", - { - discoveredScope: undefined, - oauthScope: undefined, - expectScopeCall: true, - expectedAuthScope: undefined, - authResult: "AUTHORIZED", - }, - ], - [ - "uses manual oauthScope override instead of discovered scopes", - { - discoveredScope: "discovered:scope", - oauthScope: "manual:scope", - expectScopeCall: false, - expectedAuthScope: "manual:scope", - authResult: "AUTHORIZED", - }, - ], - [ - "triggers scope discovery when oauthScope is whitespace", - { - discoveredScope: "discovered:scope", - oauthScope: " ", - expectScopeCall: true, - expectedAuthScope: "discovered:scope", - authResult: "AUTHORIZED", - }, - ], - [ - "handles auth failure after scope discovery", - { - discoveredScope: "read write", - oauthScope: undefined, - expectScopeCall: true, - expectedAuthScope: "read write", - authResult: "UNAUTHORIZED", - }, - ], - ] as const; - - test.each(testCases)( - "should %s", - async ( - _, - { - discoveredScope, - oauthScope, - expectScopeCall, - expectedAuthScope, - authResult = "AUTHORIZED", - }, - ) => { - mockDiscoverScopes.mockResolvedValue(discoveredScope); - mockAuth.mockResolvedValue(authResult as never); - setup401Error(); - - const props = - oauthScope !== undefined - ? { ...defaultProps, oauthScope } - : defaultProps; - await attemptConnection(props); - - if (expectScopeCall) { - expect(mockDiscoverScopes).toHaveBeenCalledWith( - defaultProps.sseUrl, - undefined, - ); - } else { - expect(mockDiscoverScopes).not.toHaveBeenCalled(); - } - - expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { - serverUrl: defaultProps.sseUrl, - scope: expectedAuthScope, - }); - }, - ); - - it("should handle slow scope discovery gracefully", async () => { - mockDiscoverScopes.mockImplementation( - () => - new Promise((resolve) => setTimeout(() => resolve(undefined), 100)), - ); - - setup401Error(); - await attemptConnection(); - - expect(mockDiscoverScopes).toHaveBeenCalledWith( - defaultProps.sseUrl, - undefined, - ); - expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { - serverUrl: defaultProps.sseUrl, - scope: undefined, - }); - }); - }); - - describe("MCP_PROXY_FULL_ADDRESS Configuration", () => { - beforeEach(() => { - jest.clearAllMocks(); - // Reset the mock transport objects - mockSSETransport.url = undefined; - mockSSETransport.options = undefined; - mockStreamableHTTPTransport.url = undefined; - mockStreamableHTTPTransport.options = undefined; - }); - - test("sends proxyFullAddress query parameter for stdio transport when configured", async () => { - const propsWithProxyFullAddress = { - ...defaultProps, - transportType: "stdio" as const, - command: "test-command", - args: "test-args", - env: {}, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_FULL_ADDRESS: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, - value: "https://example.com/inspector/mcp_proxy", - }, - }, - }; - - const { result } = renderHook(() => - useConnection(propsWithProxyFullAddress), - ); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the URL contains the proxyFullAddress parameter - expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( - "https://example.com/inspector/mcp_proxy", - ); - }); - - test("sends proxyFullAddress query parameter for sse transport when configured", async () => { - const propsWithProxyFullAddress = { - ...defaultProps, - transportType: "sse" as const, - sseUrl: "http://localhost:8080", - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_FULL_ADDRESS: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, - value: "https://example.com/inspector/mcp_proxy", - }, - }, - }; - - const { result } = renderHook(() => - useConnection(propsWithProxyFullAddress), - ); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the URL contains the proxyFullAddress parameter - expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( - "https://example.com/inspector/mcp_proxy", - ); - }); - - test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => { - const propsWithEmptyProxy = { - ...defaultProps, - transportType: "stdio" as const, - command: "test-command", - args: "test-args", - env: {}, - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_FULL_ADDRESS: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, - value: "", - }, - }, - }; - - const { result } = renderHook(() => useConnection(propsWithEmptyProxy)); - - await act(async () => { - await result.current.connect(); - }); - - // Check that the URL does not contain the proxyFullAddress parameter - expect( - mockSSETransport.url?.searchParams.get("proxyFullAddress"), - ).toBeNull(); - }); - - test("does not send proxyFullAddress parameter for streamable-http transport", async () => { - const propsWithStreamableHttp = { - ...defaultProps, - transportType: "streamable-http" as const, - sseUrl: "http://localhost:8080", - config: { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_FULL_ADDRESS: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, - value: "https://example.com/inspector/mcp_proxy", - }, - }, - }; - - const { result } = renderHook(() => - useConnection(propsWithStreamableHttp), - ); - - await act(async () => { - await result.current.connect(); - }); - - // Check that streamable-http transport doesn't get proxyFullAddress parameter - expect( - mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"), - ).toBeNull(); - }); - }); -}); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts deleted file mode 100644 index e14d1037f..000000000 --- a/client/src/lib/hooks/useConnection.ts +++ /dev/null @@ -1,1221 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { - SSEClientTransport, - SseError, - SSEClientTransportOptions, -} from "@modelcontextprotocol/sdk/client/sse.js"; -import { - StreamableHTTPClientTransport, - StreamableHTTPClientTransportOptions, - StreamableHTTPError, -} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { - ClientNotification, - ClientRequest, - ClientResult, - CreateMessageRequestSchema, - ListRootsRequestSchema, - ResourceUpdatedNotificationSchema, - LoggingMessageNotificationSchema, - Request, - Result, - ServerCapabilities, - PromptReference, - ResourceReference, - McpError, - CompleteResultSchema, - ErrorCode, - CancelledNotificationSchema, - ResourceListChangedNotificationSchema, - ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema, - Progress, - LoggingLevel, - ElicitRequestSchema, - Implementation, - Task, - CreateTaskResultSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema, - ListTasksResultSchema, - CancelTaskResultSchema, - TaskStatusNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import type { - AnySchema, - SchemaOutput, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { useEffect, useRef, useState } from "react"; -import { useToast } from "@/lib/hooks/useToast"; -import { ConnectionStatus, CLIENT_IDENTITY } from "../constants"; -import { Notification } from "../notificationTypes"; -import { - auth, - discoverOAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { - clearClientInformationFromSessionStorage, - InspectorOAuthClientProvider, - saveClientInformationToSessionStorage, - saveScopeToSessionStorage, - clearScopeFromSessionStorage, - discoverScopes, -} from "../auth"; -import { - getMCPProxyAddress, - getMCPTaskTtl, - getMCPServerRequestMaxTotalTimeout, - resetRequestTimeoutOnProgress, - getMCPProxyAuthToken, -} from "@/utils/configUtils"; -import { getMCPServerRequestTimeout } from "@/utils/configUtils"; -import { InspectorConfig } from "../configurationTypes"; -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { CustomHeaders } from "../types/customHeaders"; -import { resolveRefsInMessage } from "@/utils/schemaUtils"; - -interface UseConnectionOptions { - transportType: "stdio" | "sse" | "streamable-http"; - command: string; - args: string; - sseUrl: string; - env: Record; - // Custom headers support - customHeaders?: CustomHeaders; - oauthClientId?: string; - oauthClientSecret?: string; - oauthScope?: string; - config: InspectorConfig; - connectionType?: "direct" | "proxy"; - onNotification?: (notification: Notification) => void; - onStdErrNotification?: (notification: Notification) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onPendingRequest?: (request: any, resolve: any, reject: any) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onElicitationRequest?: (request: any, resolve: any) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getRoots?: () => any[]; - defaultLoggingLevel?: LoggingLevel; - serverImplementation?: Implementation; - metadata?: Record; -} - -export function useConnection({ - transportType, - command, - args, - sseUrl, - env, - customHeaders, - oauthClientId, - oauthClientSecret, - oauthScope, - config, - connectionType = "proxy", - onNotification, - onPendingRequest, - onElicitationRequest, - getRoots, - defaultLoggingLevel, - metadata = {}, -}: UseConnectionOptions) { - const [connectionStatus, setConnectionStatus] = - useState("disconnected"); - const { toast } = useToast(); - const [serverCapabilities, setServerCapabilities] = - useState(null); - const [mcpClient, setMcpClient] = useState(null); - const [clientTransport, setClientTransport] = useState( - null, - ); - const [requestHistory, setRequestHistory] = useState< - { request: string; response?: string }[] - >([]); - const [completionsSupported, setCompletionsSupported] = useState(false); - const [mcpSessionId, setMcpSessionId] = useState(null); - const [mcpProtocolVersion, setMcpProtocolVersion] = useState( - null, - ); - const [serverImplementation, setServerImplementation] = - useState(null); - - type ReceiverTaskRecord = { - task: Task; - payloadPromise: Promise; - resolvePayload: (payload: ClientResult) => void; - rejectPayload: (reason?: unknown) => void; - cleanupTimeoutId?: ReturnType; - }; - - // Tasks created locally in response to *incoming* task-augmented requests - // (e.g. `sampling/createMessage` and `elicitation/create` with `params.task`). - const receiverTasksRef = useRef>(new Map()); - - useEffect(() => { - if (!oauthClientId) { - clearClientInformationFromSessionStorage({ - serverUrl: sseUrl, - isPreregistered: true, - }); - return; - } - - const clientInformation: { client_id: string; client_secret?: string } = { - client_id: oauthClientId, - }; - - if (oauthClientSecret) { - clientInformation.client_secret = oauthClientSecret; - } - - saveClientInformationToSessionStorage({ - serverUrl: sseUrl, - clientInformation, - isPreregistered: true, - }); - }, [oauthClientId, oauthClientSecret, sseUrl]); - - useEffect(() => { - if (!oauthScope) { - clearScopeFromSessionStorage(sseUrl); - return; - } - - saveScopeToSessionStorage(sseUrl, oauthScope); - }, [oauthScope, sseUrl]); - - const pushHistory = (request: object, response?: object) => { - setRequestHistory((prev) => [ - ...prev, - { - request: JSON.stringify(request), - response: response !== undefined ? JSON.stringify(response) : undefined, - }, - ]); - }; - - const makeRequest = async ( - request: ClientRequest, - schema: T, - options?: RequestOptions & { suppressToast?: boolean }, - ): Promise> => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - try { - const abortController = new AbortController(); - - // Add metadata to the request if available, but skip for tool calls - // as they handle metadata merging separately - const shouldAddGeneralMetadata = - request.method !== "tools/call" && Object.keys(metadata).length > 0; - const requestWithMetadata = shouldAddGeneralMetadata - ? { - ...request, - params: { - ...request.params, - _meta: metadata, - }, - } - : request; - - // prepare MCP Client request options - const mcpRequestOptions: RequestOptions = { - signal: options?.signal ?? abortController.signal, - resetTimeoutOnProgress: - options?.resetTimeoutOnProgress ?? - resetRequestTimeoutOnProgress(config), - timeout: options?.timeout ?? getMCPServerRequestTimeout(config), - maxTotalTimeout: - options?.maxTotalTimeout ?? - getMCPServerRequestMaxTotalTimeout(config), - }; - - // If progress notifications are enabled, add an onprogress hook to the MCP Client request options - // This is required by SDK to reset the timeout on progress notifications - if (mcpRequestOptions.resetTimeoutOnProgress) { - mcpRequestOptions.onprogress = (params: Progress) => { - // Add progress notification to `Server Notification` window in the UI - if (onNotification) { - onNotification({ - method: "notifications/progress", - params, - }); - } - }; - } - - let response; - try { - response = await mcpClient.request( - requestWithMetadata, - schema, - mcpRequestOptions, - ); - - pushHistory(requestWithMetadata, response); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - pushHistory(requestWithMetadata, { error: errorMessage }); - throw error; - } - - return response; - } catch (e: unknown) { - if (!options?.suppressToast) { - const errorString = (e as Error).message ?? String(e); - toast({ - title: "Error", - description: errorString, - variant: "destructive", - }); - } - throw e; - } - }; - - const handleCompletion = async ( - ref: ResourceReference | PromptReference, - argName: string, - value: string, - context?: Record, - signal?: AbortSignal, - ): Promise => { - if (!mcpClient || !completionsSupported) { - return []; - } - - const request: ClientRequest = { - method: "completion/complete", - params: { - argument: { - name: argName, - value, - }, - ref, - }, - }; - - if (context) { - request["params"]["context"] = { - arguments: context, - }; - } - - try { - const response = await makeRequest(request, CompleteResultSchema, { - signal, - suppressToast: true, - }); - return response?.completion.values || []; - } catch (e: unknown) { - // Disable completions silently if the server doesn't support them. - // See https://github.com/modelcontextprotocol/specification/discussions/122 - if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) { - setCompletionsSupported(false); - return []; - } - - // Unexpected errors - show toast and rethrow - toast({ - title: "Error", - description: e instanceof Error ? e.message : String(e), - variant: "destructive", - }); - throw e; - } - }; - - const sendNotification = async (notification: ClientNotification) => { - if (!mcpClient) { - const error = new Error("MCP client not connected"); - toast({ - title: "Error", - description: error.message, - variant: "destructive", - }); - throw error; - } - - try { - await mcpClient.notification(notification); - // Log successful notifications - pushHistory(notification); - } catch (e: unknown) { - if (e instanceof McpError) { - // Log MCP protocol errors - pushHistory(notification, { error: e.message }); - } - toast({ - title: "Error", - description: e instanceof Error ? e.message : String(e), - variant: "destructive", - }); - throw e; - } - }; - - const checkProxyHealth = async () => { - try { - const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`); - const { token: proxyAuthToken, header: proxyAuthTokenHeader } = - getMCPProxyAuthToken(config); - const headers: HeadersInit = {}; - if (proxyAuthToken) { - headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; - } - const proxyHealthResponse = await fetch(proxyHealthUrl, { headers }); - const proxyHealth = await proxyHealthResponse.json(); - if (proxyHealth?.status !== "ok") { - throw new Error("MCP Proxy Server is not healthy"); - } - } catch (e) { - console.error("Couldn't connect to MCP Proxy Server", e); - throw e; - } - }; - - const is401Error = (error: unknown): boolean => { - return ( - (error instanceof SseError && error.code === 401) || - (error instanceof StreamableHTTPError && error.code === 401) || - (error instanceof Error && error.message.includes("401")) || - (error instanceof Error && error.message.includes("Unauthorized")) || - (error instanceof Error && - error.message.includes("Missing Authorization header")) - ); - }; - - const isProxyAuthError = (error: unknown): boolean => { - return ( - error instanceof Error && - error.message.includes("Authentication required. Use the session token") - ); - }; - - const handleAuthError = async (error: unknown) => { - if (is401Error(error)) { - let scope = oauthScope?.trim(); - if (!scope) { - // Only discover resource metadata when we need to discover scopes - let resourceMetadata; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - new URL("/", sseUrl), - ); - } catch { - // Resource metadata is optional, continue without it - } - scope = await discoverScopes(sseUrl, resourceMetadata); - } - - saveScopeToSessionStorage(sseUrl, scope); - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - - try { - const result = await auth(serverAuthProvider, { - serverUrl: sseUrl, - scope, - }); - return result === "AUTHORIZED"; - } catch (authError) { - // Show user-friendly error message for OAuth failures - toast({ - title: "OAuth Authentication Failed", - description: - authError instanceof Error ? authError.message : String(authError), - variant: "destructive", - }); - return false; - } - } - - return false; - }; - - const captureResponseHeaders = (response: Response): void => { - const sessionId = response.headers.get("mcp-session-id"); - const protocolVersion = response.headers.get("mcp-protocol-version"); - if (sessionId && sessionId !== mcpSessionId) { - setMcpSessionId(sessionId); - } - if (protocolVersion && protocolVersion !== mcpProtocolVersion) { - setMcpProtocolVersion(protocolVersion); - } - }; - - const connect = async (_e?: unknown, retryCount: number = 0) => { - const clientCapabilities = { - capabilities: { - sampling: {}, - elicitation: {}, - roots: { - listChanged: true, - }, - tasks: { - list: {}, - cancel: {}, - ...(onPendingRequest || onElicitationRequest - ? { - requests: { - ...(onPendingRequest - ? { sampling: { createMessage: {} } } - : undefined), - ...(onElicitationRequest - ? { elicitation: { create: {} } } - : undefined), - }, - } - : undefined), - }, - }, - }; - - const client = new Client( - CLIENT_IDENTITY, - clientCapabilities, - ); - - // Only check proxy health for proxy connections - if (connectionType === "proxy") { - try { - await checkProxyHealth(); - } catch { - setConnectionStatus("error-connecting-to-proxy"); - return; - } - } - - let lastRequest = ""; - try { - // Inject auth manually instead of using SSEClientTransport, because we're - // proxying through the inspector server first. - const headers: HeadersInit = {}; - - // Create an auth provider with the current server URL - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - - // Use custom headers (migration is handled in App.tsx) - let finalHeaders: CustomHeaders = customHeaders || []; - - const isEmptyAuthHeader = (header: CustomHeaders[number]) => - header.name.trim().toLowerCase() === "authorization" && - header.value.trim().toLowerCase() === "bearer"; - - // Check for empty Authorization headers and show validation error - const hasEmptyAuthHeader = finalHeaders.some( - (header) => header.enabled && isEmptyAuthHeader(header), - ); - - if (hasEmptyAuthHeader) { - toast({ - title: "Invalid Authorization Header", - description: - "Authorization header is enabled but empty. Please add a token or disable the header.", - variant: "destructive", - }); - } - - const needsOAuthToken = !finalHeaders.some( - (header) => - header.enabled && - header.name.trim().toLowerCase() === "authorization", - ); - - if (needsOAuthToken) { - const oauthToken = (await serverAuthProvider.tokens())?.access_token; - if (oauthToken) { - // Add the OAuth token - finalHeaders = [ - // Remove any existing Authorization headers with empty tokens - ...finalHeaders.filter((header) => !isEmptyAuthHeader(header)), - { - name: "Authorization", - value: `Bearer ${oauthToken}`, - enabled: true, - }, - ]; - } - } - - // Process all enabled custom headers - const customHeaderNames: string[] = []; - finalHeaders.forEach((header) => { - if (header.enabled && header.name.trim() && header.value.trim()) { - const headerName = header.name.trim(); - const headerValue = header.value.trim(); - - headers[headerName] = headerValue; - - // Track custom header names for server processing - if (headerName.toLowerCase() !== "authorization") { - customHeaderNames.push(headerName); - } - } - }); - - // Add custom header names as a special request header for server processing - if (customHeaderNames.length > 0) { - headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames); - } - - // Create appropriate transport - let transportOptions: - | StreamableHTTPClientTransportOptions - | SSEClientTransportOptions; - - let serverUrl: URL; - - // Determine connection URL based on the connection type - if (connectionType === "direct" && transportType !== "stdio") { - // Direct connection - use the provided URL directly (not available for STDIO) - serverUrl = new URL(sseUrl); - - const requestHeaders = { ...headers }; - if (mcpSessionId) { - requestHeaders["mcp-session-id"] = mcpSessionId; - } - switch (transportType) { - case "sse": - requestHeaders["Accept"] = "text/event-stream"; - requestHeaders["content-type"] = "application/json"; - transportOptions = { - authProvider: serverAuthProvider, - fetch: async ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => { - const response = await fetch(url, { - ...init, - headers: requestHeaders, - }); - - // Capture protocol-related headers from response - captureResponseHeaders(response); - return response; - }, - requestInit: { - headers: requestHeaders, - }, - }; - break; - - case "streamable-http": - transportOptions = { - authProvider: serverAuthProvider, - fetch: async ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => { - requestHeaders["Accept"] = - "text/event-stream, application/json"; - requestHeaders["Content-Type"] = "application/json"; - const response = await fetch(url, { - headers: requestHeaders, - ...init, - }); - - // Capture protocol-related headers from response - captureResponseHeaders(response); - - return response; - }, - requestInit: { - headers: requestHeaders, - }, - // TODO these should be configurable... - reconnectionOptions: { - maxReconnectionDelay: 30000, - initialReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }, - }; - break; - } - } else { - // Proxy connection (default behavior) - // Add proxy authentication headers for proxy connections only - const { token: proxyAuthToken, header: proxyAuthTokenHeader } = - getMCPProxyAuthToken(config); - const proxyHeaders: HeadersInit = {}; - if (proxyAuthToken) { - proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; - } - - let mcpProxyServerUrl; - switch (transportType) { - case "stdio": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); - mcpProxyServerUrl.searchParams.append("command", command); - mcpProxyServerUrl.searchParams.append("args", args); - mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); - - const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddress) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddress, - ); - } - transportOptions = { - authProvider: serverAuthProvider, - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; - } - - case "sse": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); - - const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddressSSE) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddressSSE, - ); - } - transportOptions = { - authProvider: serverAuthProvider, - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; - } - - case "streamable-http": - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); - transportOptions = { - authProvider: serverAuthProvider, - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - // TODO these should be configurable... - reconnectionOptions: { - maxReconnectionDelay: 30000, - initialReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }, - }; - break; - } - serverUrl = mcpProxyServerUrl as URL; - serverUrl.searchParams.append("transportType", transportType); - } - - if (onNotification) { - [ - CancelledNotificationSchema, - LoggingMessageNotificationSchema, - ResourceUpdatedNotificationSchema, - ResourceListChangedNotificationSchema, - ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, - ].forEach((notificationSchema) => { - client.setNotificationHandler(notificationSchema, onNotification); - }); - - client.fallbackNotificationHandler = ( - notification: Notification, - ): Promise => { - onNotification(notification); - return Promise.resolve(); - }; - } - - let capabilities; - try { - const transport = - transportType === "streamable-http" - ? new StreamableHTTPClientTransport(serverUrl, { - sessionId: undefined, - ...transportOptions, - }) - : new SSEClientTransport(serverUrl, transportOptions); - - await client.connect(transport as Transport); - - const protocolOnMessage = transport.onmessage; - if (protocolOnMessage) { - transport.onmessage = (message) => { - const resolvedMessage = resolveRefsInMessage(message); - protocolOnMessage(resolvedMessage); - }; - } - - setClientTransport(transport); - - capabilities = client.getServerCapabilities(); - const serverInfo = client.getServerVersion(); - setServerImplementation(serverInfo || null); - const initializeRequest = { - method: "initialize", - }; - pushHistory(initializeRequest, { - capabilities, - serverInfo: client.getServerVersion(), - instructions: client.getInstructions(), - }); - } catch (error) { - console.error( - connectionType === "direct" - ? `Failed to connect directly to MCP Server at: ${serverUrl}:` - : `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`, - error, - ); - - // Check if it's a proxy auth error - if (isProxyAuthError(error)) { - toast({ - title: "Proxy Authentication Required", - description: - "Please enter the session token from the proxy server console in the Configuration settings.", - variant: "destructive", - }); - setConnectionStatus("error"); - return; - } - - const shouldRetry = await handleAuthError(error); - if (shouldRetry) { - return connect(undefined, retryCount + 1); - } - if (is401Error(error)) { - // Don't set error state if we're about to redirect for auth - - return; - } - throw error; - } - setServerCapabilities(capabilities ?? null); - setCompletionsSupported(capabilities?.completions !== undefined); - - const nowIso = () => new Date().toISOString(); - - const makeTaskId = () => { - // Prefer UUID when available; otherwise fall back to a reasonably unique id. - const cryptoAny = globalThis.crypto as unknown as - | { randomUUID?: () => string } - | undefined; - return ( - cryptoAny?.randomUUID?.() ?? - `task_${Date.now()}_${Math.random().toString(16).slice(2)}` - ); - }; - - const emitTaskStatus = async (task: Task) => { - // Best-effort; task status notifications are optional. - try { - const notification: ClientNotification = { - method: "notifications/tasks/status", - params: task, - } as unknown as ClientNotification; - await client.notification(notification); - pushHistory(notification); - } catch (e) { - console.warn("Failed to send notifications/tasks/status", e); - } - }; - - const upsertReceiverTask = async (task: Task) => { - // Update task record and emit status notification. - const record = receiverTasksRef.current.get(task.taskId); - if (record) { - receiverTasksRef.current.set(task.taskId, { ...record, task }); - } - await emitTaskStatus(task); - }; - - const createReceiverTask = (opts: { - ttl?: number; - initialStatus: Task["status"]; - statusMessage?: string; - pollInterval?: number; - }): ReceiverTaskRecord => { - const taskId = makeTaskId(); - const createdAt = nowIso(); - const ttl = opts.ttl ?? getMCPTaskTtl(config); - - let resolvePayload: (payload: ClientResult) => void = () => undefined; - let rejectPayload: (reason?: unknown) => void = () => undefined; - const payloadPromise = new Promise((resolve, reject) => { - resolvePayload = resolve; - rejectPayload = reject; - }); - - const task: Task = { - taskId, - status: opts.initialStatus, - ttl, - createdAt, - lastUpdatedAt: createdAt, - ...(opts.pollInterval !== undefined - ? { pollInterval: opts.pollInterval } - : undefined), - ...(opts.statusMessage ? { statusMessage: opts.statusMessage } : {}), - }; - - const record: ReceiverTaskRecord = { - task, - payloadPromise, - resolvePayload, - rejectPayload, - }; - - // Cleanup after TTL (best-effort). - if (ttl !== null && ttl > 0) { - record.cleanupTimeoutId = setTimeout(() => { - receiverTasksRef.current.delete(taskId); - }, ttl); - } - - receiverTasksRef.current.set(taskId, record); - void emitTaskStatus(task); - return record; - }; - - // Server -> client Tasks handlers (receiver side) - client.setRequestHandler(ListTasksRequestSchema, async () => { - return { - tasks: Array.from(receiverTasksRef.current.values()).map( - (r) => r.task, - ), - }; - }); - - client.setRequestHandler(GetTaskRequestSchema, async (request) => { - const record = receiverTasksRef.current.get(request.params.taskId); - if (!record) { - throw new McpError( - ErrorCode.InvalidParams, - `Unknown taskId: ${request.params.taskId}`, - ); - } - return record.task; - }); - - client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => { - const record = receiverTasksRef.current.get(request.params.taskId); - if (!record) { - throw new McpError( - ErrorCode.InvalidParams, - `Unknown taskId: ${request.params.taskId}`, - ); - } - - // Block until the task payload is ready. - return await record.payloadPromise; - }); - - client.setRequestHandler(CancelTaskRequestSchema, async (request) => { - const record = receiverTasksRef.current.get(request.params.taskId); - if (!record) { - throw new McpError( - ErrorCode.InvalidParams, - `Unknown taskId: ${request.params.taskId}`, - ); - } - - const terminalStatuses: Task["status"][] = [ - "completed", - "failed", - "cancelled", - ]; - - if (!terminalStatuses.includes(record.task.status)) { - const updated: Task = { - ...record.task, - status: "cancelled", - lastUpdatedAt: nowIso(), - statusMessage: "Cancelled", - }; - receiverTasksRef.current.set(request.params.taskId, { - ...record, - task: updated, - }); - - // Unblock any pending `tasks/result`. - record.rejectPayload( - new McpError(ErrorCode.InternalError, "Task was cancelled"), - ); - - await emitTaskStatus(updated); - } - - return receiverTasksRef.current.get(request.params.taskId)!.task; - }); - - if (onPendingRequest) { - client.setRequestHandler(CreateMessageRequestSchema, (request) => { - const taskSpec = (request as { params?: { task?: { ttl?: number } } }) - .params?.task; - - if (!taskSpec) { - return new Promise((resolve, reject) => { - onPendingRequest(request, resolve, reject); - }); - } - - // Task-augmented sampling request: return a task immediately and - // allow the server to poll via `tasks/get` and `tasks/result`. - const record = createReceiverTask({ - ttl: taskSpec.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - - // Background runner to complete and resolve this specific task record. - void (async () => { - try { - const payload = await new Promise((resolve, reject) => { - onPendingRequest(request, resolve, reject); - }); - record.resolvePayload(payload as ClientResult); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: nowIso(), - }; - receiverTasksRef.current.set(record.task.taskId, { - ...record, - task: updated, - }); - await upsertReceiverTask(updated); - } catch (e) { - record.rejectPayload(e); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: nowIso(), - statusMessage: e instanceof Error ? e.message : "Task failed", - }; - receiverTasksRef.current.set(record.task.taskId, { - ...record, - task: updated, - }); - await upsertReceiverTask(updated); - } - })(); - - const createTaskResult: SchemaOutput = - { - task: record.task, - }; - return createTaskResult; - }); - } - - if (getRoots) { - client.setRequestHandler(ListRootsRequestSchema, async () => { - return { roots: getRoots() }; - }); - } - - if (onElicitationRequest) { - client.setRequestHandler(ElicitRequestSchema, (request) => { - const taskSpec = (request as { params?: { task?: { ttl?: number } } }) - .params?.task; - - if (!taskSpec) { - return new Promise((resolve) => { - onElicitationRequest(request, resolve); - }); - } - - const record = createReceiverTask({ - ttl: taskSpec.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - - // Run elicitation flow and resolve the task payload. - void (async () => { - try { - const payload = await new Promise((resolve) => { - onElicitationRequest(request, resolve); - }); - record.resolvePayload(payload as ClientResult); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: nowIso(), - }; - receiverTasksRef.current.set(record.task.taskId, { - ...record, - task: updated, - }); - await upsertReceiverTask(updated); - } catch (e) { - record.rejectPayload(e); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: nowIso(), - statusMessage: e instanceof Error ? e.message : "Task failed", - }; - receiverTasksRef.current.set(record.task.taskId, { - ...record, - task: updated, - }); - await upsertReceiverTask(updated); - } - })(); - - const createTaskResult: SchemaOutput = - { - task: record.task, - }; - return createTaskResult; - }); - } - - if (capabilities?.logging && defaultLoggingLevel) { - lastRequest = "logging/setLevel"; - await client.setLoggingLevel(defaultLoggingLevel); - pushHistory( - { - method: "logging/setLevel", - params: { - level: defaultLoggingLevel, - }, - }, - {}, - ); - lastRequest = ""; - } - - setMcpClient(client); - setConnectionStatus("connected"); - } catch (e) { - if ( - lastRequest === "logging/setLevel" && - e instanceof McpError && - e.code === ErrorCode.MethodNotFound - ) { - toast({ - title: "Error", - description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`, - variant: "destructive", - }); - } else { - toast({ - title: "Connection error", - description: `Connection failed: "${e}"`, - variant: "destructive", - }); - } - console.error(e); - setConnectionStatus("error"); - } - }; - - const cancelTask = async (taskId: string) => { - return makeRequest( - { - method: "tasks/cancel", - params: { taskId }, - }, - CancelTaskResultSchema, - ); - }; - - const listTasks = async (cursor?: string) => { - return makeRequest( - { - method: "tasks/list", - params: { cursor }, - }, - ListTasksResultSchema, - ); - }; - - const disconnect = async () => { - // Clear any receiver-side tasks + cleanup timers - receiverTasksRef.current.forEach((record) => { - if (record.cleanupTimeoutId) { - clearTimeout(record.cleanupTimeoutId); - } - }); - receiverTasksRef.current.clear(); - - if (transportType === "streamable-http") - await ( - clientTransport as StreamableHTTPClientTransport - ).terminateSession(); - await mcpClient?.close(); - const authProvider = new InspectorOAuthClientProvider(sseUrl); - authProvider.clear(); - setMcpClient(null); - setClientTransport(null); - setConnectionStatus("disconnected"); - setCompletionsSupported(false); - setServerCapabilities(null); - setMcpSessionId(null); - setMcpProtocolVersion(null); - }; - - const clearRequestHistory = () => { - setRequestHistory([]); - setServerImplementation(null); - }; - - return { - connectionStatus, - serverCapabilities, - serverImplementation, - mcpClient, - requestHistory, - clearRequestHistory, - makeRequest, - cancelTask, - listTasks, - sendNotification, - handleCompletion, - completionsSupported, - connect, - disconnect, - }; -} diff --git a/client/src/utils/__tests__/configUtils.test.ts b/client/src/utils/__tests__/configUtils.test.ts deleted file mode 100644 index 86dccbdf1..000000000 --- a/client/src/utils/__tests__/configUtils.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getMCPProxyAuthToken } from "../configUtils"; -import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants"; -import { InspectorConfig } from "../../lib/configurationTypes"; - -describe("configUtils", () => { - describe("getMCPProxyAuthToken", () => { - test("returns token and default header name", () => { - const config: InspectorConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "test-token-123", - }, - }; - - const result = getMCPProxyAuthToken(config); - - expect(result).toEqual({ - token: "test-token-123", - header: "X-MCP-Proxy-Auth", - }); - }); - - test("returns empty token when not configured", () => { - const config: InspectorConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "", - }, - }; - - const result = getMCPProxyAuthToken(config); - - expect(result).toEqual({ - token: "", - header: "X-MCP-Proxy-Auth", - }); - }); - - test("always returns X-MCP-Proxy-Auth as header name", () => { - const config: InspectorConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: "any-token", - }, - }; - - const result = getMCPProxyAuthToken(config); - - expect(result.header).toBe("X-MCP-Proxy-Auth"); - }); - - test("handles null/undefined value gracefully", () => { - const config: InspectorConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - MCP_PROXY_AUTH_TOKEN: { - ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, - value: null as unknown as string, - }, - }; - - const result = getMCPProxyAuthToken(config); - - expect(result).toEqual({ - token: null, - header: "X-MCP-Proxy-Auth", - }); - }); - }); -}); diff --git a/client/tsconfig.jest.json b/client/tsconfig.jest.json deleted file mode 100644 index 137fc0a58..000000000 --- a/client/tsconfig.jest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.app.json", - "compilerOptions": { - "jsx": "react-jsx", - "esModuleInterop": true, - "module": "ESNext", - "moduleResolution": "node" - }, - "include": ["src"] -} diff --git a/client/vite.config.ts b/client/vite.config.ts deleted file mode 100644 index fa817c7b0..000000000 --- a/client/vite.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import react from "@vitejs/plugin-react"; -import path from "path"; -import { defineConfig } from "vite"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - server: { - host: true, - }, - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, - build: { - minify: false, - rollupOptions: { - output: { - manualChunks: undefined, - }, - }, - }, -}); diff --git a/configs/mcp.json b/configs/mcp.json new file mode 100644 index 000000000..0ce64f0d7 --- /dev/null +++ b/configs/mcp.json @@ -0,0 +1,53 @@ +{ + "mcpServers": { + "composable-demo": { + "command": "node", + "args": [ + "test-servers/build/server-composable.js", + "--config", + "test-servers/configs/demo.json" + ] + }, + "url-elicitation-form": { + "command": "node", + "args": [ + "test-servers/build/server-composable.js", + "--config", + "test-servers/configs/url-elicitation-form.json" + ] + }, + "everything": { + "command": "npx", + "args": [ + "@modelcontextprotocol/server-everything" + ], + "env": { + "HELLO": "Hello MCP!" + } + }, + "myserver": { + "command": "node", + "args": [ + "build/index.js", + "arg1", + "arg2" + ], + "env": { + "KEY": "value", + "KEY2": "value2" + } + }, + "hosted-everything": { + "type": "streamable-http", + "url": "https://example-server.modelcontextprotocol.io/mcp" + }, + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp" + }, + "stytch": { + "type": "streamable-http", + "url": "https://stytch-as-demo.val.run/mcp" + } + } +} diff --git a/configs/mcpapps.json b/configs/mcpapps.json new file mode 100644 index 000000000..ecddd374f --- /dev/null +++ b/configs/mcpapps.json @@ -0,0 +1,213 @@ +{ + "mcpServers": { + "budget-allocator": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-budget-allocator", + "--stdio" + ] + }, + "pdf": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-pdf", + "--stdio" + ] + }, + "transcript": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-transcript", + "--stdio" + ] + }, + "video-resource": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-video-resource", + "--stdio" + ] + }, + "map": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-map", + "--stdio" + ] + }, + "threejs": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-threejs", + "--stdio" + ] + }, + "shadertoy": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-shadertoy", + "--stdio" + ] + }, + "sheet-music": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-sheet-music", + "--stdio" + ] + }, + "wiki-explorer": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-wiki-explorer", + "--stdio" + ] + }, + "cohort-heatmap": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-cohort-heatmap", + "--stdio" + ] + }, + "customer-segmentation": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-customer-segmentation", + "--stdio" + ] + }, + "scenario-modeler": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-scenario-modeler", + "--stdio" + ] + }, + "system-monitor": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-system-monitor", + "--stdio" + ] + }, + "basic-react": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-react", + "--stdio" + ] + }, + "basic-vanillajs": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-vanillajs", + "--stdio" + ] + }, + "basic-vue": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-vue", + "--stdio" + ] + }, + "basic-svelte": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-svelte", + "--stdio" + ] + }, + "basic-preact": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-preact", + "--stdio" + ] + }, + "basic-solid": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-basic-solid", + "--stdio" + ] + } + } +} diff --git a/core/__tests__/auth/discovery.test.ts b/core/__tests__/auth/discovery.test.ts new file mode 100644 index 000000000..591701291 --- /dev/null +++ b/core/__tests__/auth/discovery.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { discoverScopes } from "../../auth/discovery.js"; +import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Mock SDK functions +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + discoverAuthorizationServerMetadata: vi.fn(), +})); + +describe("OAuth Scope Discovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return scopes from resource metadata when available", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["read", "write"], + }); + + const resourceMetadata: OAuthProtectedResourceMetadata = { + resource: "http://localhost:3000", + authorization_servers: ["http://localhost:3000"], + scopes_supported: ["read", "write", "admin"], + }; + + const scopes = await discoverScopes( + "http://localhost:3000", + resourceMetadata, + ); + + expect(scopes).toBe("read write admin"); + }); + + it("should fall back to OAuth metadata scopes when resource metadata has no scopes", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["read", "write"], + }); + + const resourceMetadata: OAuthProtectedResourceMetadata = { + resource: "http://localhost:3000", + authorization_servers: ["http://localhost:3000"], + scopes_supported: [], + }; + + const scopes = await discoverScopes( + "http://localhost:3000", + resourceMetadata, + ); + + expect(scopes).toBe("read write"); + }); + + it("should fall back to OAuth metadata scopes when resource metadata is not provided", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["read", "write"], + }); + + const scopes = await discoverScopes("http://localhost:3000"); + + expect(scopes).toBe("read write"); + }); + + it("should return undefined when no scopes are available", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: [], + }); + + const scopes = await discoverScopes("http://localhost:3000"); + + expect(scopes).toBeUndefined(); + }); + + it("should return undefined when discovery fails", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockRejectedValue( + new Error("Discovery failed"), + ); + + const scopes = await discoverScopes("http://localhost:3000"); + + expect(scopes).toBeUndefined(); + }); + + it("should return undefined when metadata is undefined", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(undefined); + + const scopes = await discoverScopes("http://localhost:3000"); + + expect(scopes).toBeUndefined(); + }); + + it("should use OAuth metadata scopes when resource has scopes_supported undefined", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["read", "write"], + }); + + const resourceMetadata: OAuthProtectedResourceMetadata = { + resource: "http://localhost:3000", + authorization_servers: ["http://localhost:3000"], + scopes_supported: undefined as unknown as string[], + }; + + const scopes = await discoverScopes( + "http://localhost:3000", + resourceMetadata, + ); + + expect(scopes).toBe("read write"); + }); + + it("should return single scope when only one scope is supported", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["openid"], + }); + + const scopes = await discoverScopes("http://localhost:3000"); + + expect(scopes).toBe("openid"); + }); + + it("should pass fetchFn to discoverAuthorizationServerMetadata when provided", async () => { + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + const mockFetchFn = vi.fn(); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + scopes_supported: ["read", "write"], + }); + + await discoverScopes("http://localhost:3000", undefined, mockFetchFn); + + expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith( + new URL("/", "http://localhost:3000"), + { fetchFn: mockFetchFn }, + ); + }); +}); diff --git a/core/__tests__/auth/oauth-callback-server.test.ts b/core/__tests__/auth/oauth-callback-server.test.ts new file mode 100644 index 000000000..f900fcb91 --- /dev/null +++ b/core/__tests__/auth/oauth-callback-server.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + createOAuthCallbackServer, + type OAuthCallbackServer, +} from "../../auth/node/oauth-callback-server.js"; + +describe("OAuthCallbackServer", () => { + let server: OAuthCallbackServer; + + afterEach(async () => { + if (server) await server.stop(); + }); + + it("start() returns port and redirectUrl", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + expect(result.port).toBeGreaterThan(0); + expect(result.redirectUrl).toBe( + `http://127.0.0.1:${result.port}/oauth/callback`, + ); + }); + + it("start() supports custom host, path, and port", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ + hostname: "127.0.0.1", + port: 0, + path: "/custom/path", + }); + + expect(result.redirectUrl).toBe( + `http://127.0.0.1:${result.port}/custom/path`, + ); + }); + + it("GET /oauth/callback?code=abc&state=xyz returns 200 and invokes onCallback", async () => { + server = createOAuthCallbackServer(); + const received: { code?: string; state?: string } = {}; + const result = await server.start({ + port: 0, + onCallback: async (p) => { + received.code = p.code; + received.state = p.state; + }, + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=authcode123&state=mystate`, + ); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("OAuth complete"); + expect(html).toContain("close this window"); + expect(received.code).toBe("authcode123"); + expect(received.state).toBe("mystate"); + }); + + it("GET /oauth/callback?code=abc returns 200 and invokes onCallback without state", async () => { + server = createOAuthCallbackServer(); + const received: { code?: string; state?: string } = {}; + const result = await server.start({ + port: 0, + onCallback: async (p) => { + received.code = p.code; + received.state = p.state; + }, + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=xyz`, + ); + + expect(res.status).toBe(200); + expect(received.code).toBe("xyz"); + expect(received.state).toBeUndefined(); + }); + + it("GET /oauth/callback/guided returns 404 (single path only)", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback/guided?code=guided-code`, + ); + + expect(res.status).toBe(404); + }); + + it("GET /oauth/callback?error=access_denied returns 400 and invokes onError", async () => { + server = createOAuthCallbackServer(); + const errors: Array<{ + error: string; + error_description?: string | null; + }> = []; + const result = await server.start({ + port: 0, + onError: (p) => errors.push(p), + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?error=access_denied&error_description=User%20denied`, + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("OAuth failed"); + expect(html).toContain("access_denied"); + expect(errors).toHaveLength(1); + expect(errors[0]!.error).toBe("access_denied"); + expect(errors[0]!.error_description).toBe("User denied"); + }); + + it("GET /oauth/callback (missing code and error) returns 400", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?state=foo`, + ); + + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("OAuth failed"); + }); + + it("GET /other returns 404", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch(`http://localhost:${result.port}/other`); + + expect(res.status).toBe(404); + }); + + it("POST /oauth/callback returns 405", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=x`, + { method: "POST" }, + ); + + expect(res.status).toBe(405); + }); + + it("stops server after first successful callback so second request fails", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ + port: 0, + onCallback: async () => {}, + }); + + const first = await fetch( + `http://localhost:${result.port}/oauth/callback?code=first`, + ); + expect(first.status).toBe(200); + + // Server stops after sending 200, so second request gets connection refused + await expect( + fetch(`http://localhost:${result.port}/oauth/callback?code=second`), + ).rejects.toThrow(); + }); + + it("stop() closes the server", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ port: 0 }); + await server.stop(); + + await expect( + fetch(`http://localhost:${result.port}/oauth/callback?code=x`), + ).rejects.toThrow(); + }); + + it("onCallback rejection returns 500 and error HTML", async () => { + server = createOAuthCallbackServer(); + const result = await server.start({ + port: 0, + onCallback: async () => { + throw new Error("exchange failed"); + }, + }); + + const res = await fetch( + `http://localhost:${result.port}/oauth/callback?code=abc`, + ); + + expect(res.status).toBe(500); + const html = await res.text(); + expect(html).toContain("OAuth failed"); + expect(html).toContain("exchange failed"); + }); +}); diff --git a/core/__tests__/auth/providers.test.ts b/core/__tests__/auth/providers.test.ts new file mode 100644 index 000000000..433ea4bb0 --- /dev/null +++ b/core/__tests__/auth/providers.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ConsoleNavigation, CallbackNavigation } from "../../auth/providers.js"; +import { BrowserNavigation } from "../../auth/browser/providers.js"; + +describe("OAuthNavigation", () => { + describe("ConsoleNavigation", () => { + it("should log authorization URL to console", () => { + const navigation = new ConsoleNavigation(); + const authUrl = new URL("http://example.com/authorize?client_id=123"); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + navigation.navigateToAuthorization(authUrl); + + expect(consoleSpy).toHaveBeenCalledWith( + "Please navigate to: http://example.com/authorize?client_id=123", + ); + + consoleSpy.mockRestore(); + }); + }); + + describe("CallbackNavigation", () => { + it("should invoke callback and store authorization URL for retrieval", () => { + const callback = vi.fn(); + const navigation = new CallbackNavigation(callback); + const authUrl = new URL("http://example.com/authorize?client_id=123"); + + expect(navigation.getAuthorizationUrl()).toBeNull(); + + navigation.navigateToAuthorization(authUrl); + + expect(callback).toHaveBeenCalledWith(authUrl); + expect(navigation.getAuthorizationUrl()).toBe(authUrl); + }); + }); + + describe("BrowserNavigation", () => { + // Mock window.location for Node.js environment + type GlobalWithWindow = typeof globalThis & { + window?: { location: { href: string } }; + }; + const originalWindow = (global as GlobalWithWindow).window; + + beforeEach(() => { + (global as GlobalWithWindow).window = { + location: { href: "http://localhost:5173" }, + } as GlobalWithWindow["window"]; + }); + + afterEach(() => { + (global as GlobalWithWindow).window = originalWindow; + }); + + it("should set window.location.href to authorization URL", () => { + const navigation = new BrowserNavigation(); + const authUrl = new URL("http://example.com/authorize?client_id=123"); + + navigation.navigateToAuthorization(authUrl); + + expect((global as GlobalWithWindow).window!.location.href).toBe( + authUrl.toString(), + ); + }); + + it("should throw error in non-browser environment", () => { + (global as GlobalWithWindow).window = + undefined as unknown as GlobalWithWindow["window"]; + const navigation = new BrowserNavigation(); + const authUrl = new URL("http://example.com/authorize"); + + expect(() => navigation.navigateToAuthorization(authUrl)).toThrow( + "BrowserNavigation requires browser environment", + ); + }); + }); +}); diff --git a/core/__tests__/auth/state-machine.test.ts b/core/__tests__/auth/state-machine.test.ts new file mode 100644 index 000000000..3fb153ae0 --- /dev/null +++ b/core/__tests__/auth/state-machine.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + OAuthStateMachine, + oauthTransitions, +} from "../../auth/state-machine.js"; +import type { AuthGuidedState, OAuthStep } from "../../auth/types.js"; +import { EMPTY_GUIDED_STATE } from "../../auth/types.js"; +import type { BaseOAuthClientProvider } from "../../auth/providers.js"; +import type { + OAuthMetadata, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Mock SDK functions +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + discoverAuthorizationServerMetadata: vi.fn(), + discoverOAuthProtectedResourceMetadata: vi.fn(), + registerClient: vi.fn(), + startAuthorization: vi.fn(), + exchangeAuthorization: vi.fn(), + selectResourceURL: vi.fn(), +})); + +describe("OAuthStateMachine", () => { + let mockProvider: BaseOAuthClientProvider; + let updateState: (updates: Partial) => void; + let state: AuthGuidedState; + + beforeEach(() => { + state = { ...EMPTY_GUIDED_STATE }; + updateState = vi.fn((updates: Partial) => { + state = { ...state, ...updates }; + }); + + mockProvider = { + serverUrl: "http://localhost:3000", + redirectUrl: "http://localhost:3000/callback", + scope: "read write", + clientMetadata: { + redirect_uris: ["http://localhost:3000/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + client_name: "Test Client", + scope: "read write", + }, + clientInformation: vi.fn(), + saveClientInformation: vi.fn(), + tokens: vi.fn(), + saveTokens: vi.fn(), + codeVerifier: vi.fn(() => "test-code-verifier"), + clear: vi.fn(), + state: vi.fn(() => "test-state"), + getServerMetadata: vi.fn(() => null), + saveServerMetadata: vi.fn(), + } as unknown as BaseOAuthClientProvider; + }); + + describe("oauthTransitions", () => { + it("should have transitions for all OAuth steps", () => { + const steps: OAuthStep[] = [ + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", + ]; + + steps.forEach((step) => { + expect(oauthTransitions[step]).toBeDefined(); + expect(oauthTransitions[step].canTransition).toBeDefined(); + expect(oauthTransitions[step].execute).toBeDefined(); + }); + }); + }); + + describe("OAuthStateMachine", () => { + it("should create state machine instance", () => { + const stateMachine = new OAuthStateMachine( + "http://localhost:3000", + mockProvider, + updateState, + ); + + expect(stateMachine).toBeDefined(); + }); + + it("should update state when executeStep is called", async () => { + const stateMachine = new OAuthStateMachine( + "http://localhost:3000", + mockProvider, + updateState, + ); + + const { discoverAuthorizationServerMetadata } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + } as OAuthMetadata); + + await stateMachine.executeStep(state); + + expect(updateState).toHaveBeenCalled(); + }); + }); + + describe("Resource metadata discovery and selection", () => { + const serverUrl = "http://localhost:3000"; + const resourceMetadata = { + resource: "http://localhost:3000", + authorization_servers: ["http://localhost:3000"], + scopes_supported: ["read", "write"], + }; + + beforeEach(async () => { + const { + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + selectResourceURL, + } = await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + } as OAuthMetadata); + vi.mocked(discoverOAuthProtectedResourceMetadata).mockReset(); + vi.mocked(selectResourceURL).mockReset(); + }); + + it("should discover resource metadata from well-known and use first authorization server", async () => { + const selectedResource = new URL("http://localhost:3000"); + const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue( + resourceMetadata as OAuthProtectedResourceMetadata, + ); + vi.mocked(selectResourceURL).mockResolvedValue(selectedResource); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await stateMachine.executeStep(state); + + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( + serverUrl, + ); + expect(selectResourceURL).toHaveBeenCalledWith( + serverUrl, + mockProvider, + resourceMetadata, + ); + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadata, + resource: selectedResource, + resourceMetadataError: null, + authServerUrl: new URL("http://localhost:3000"), + oauthStep: "client_registration", + }), + ); + }); + + it("should call selectResourceURL only when resource metadata is present", async () => { + const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( + new Error( + "Resource server does not implement OAuth 2.0 Protected Resource Metadata.", + ), + ); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await stateMachine.executeStep(state); + + expect(selectResourceURL).not.toHaveBeenCalled(); + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadata: null, + resourceMetadataError: expect.any(Error), + oauthStep: "client_registration", + }), + ); + }); + + it("should use default auth server URL when discovery fails", async () => { + const { + discoverOAuthProtectedResourceMetadata, + discoverAuthorizationServerMetadata, + } = await import("@modelcontextprotocol/sdk/client/auth.js"); + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( + new Error("Discovery failed"), + ); + vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + } as OAuthMetadata); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await stateMachine.executeStep(state); + + expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith( + new URL("/", serverUrl), + {}, // No fetchFn when not provided (conditional spread omits it) + ); + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + authServerUrl: new URL("/", serverUrl), + }), + ); + }); + + it("should use default auth server when metadata has empty authorization_servers", async () => { + const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + const metaNoServers = { + ...resourceMetadata, + authorization_servers: [] as string[], + }; + vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue( + metaNoServers as OAuthProtectedResourceMetadata, + ); + vi.mocked(selectResourceURL).mockResolvedValue( + new URL("http://localhost:3000"), + ); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + ); + await stateMachine.executeStep(state); + + expect(selectResourceURL).toHaveBeenCalledWith( + serverUrl, + mockProvider, + metaNoServers, + ); + expect(updateState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadata: metaNoServers, + authServerUrl: new URL("/", serverUrl), + oauthStep: "client_registration", + }), + ); + }); + + it("should pass fetchFn to registerClient when provided", async () => { + const { registerClient } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + const mockFetchFn = vi.fn(); + vi.mocked(registerClient).mockResolvedValue({ + redirect_uris: ["http://localhost/callback"], + client_id: "registered-client-id", + }); + + const stateMachine = new OAuthStateMachine( + serverUrl, + mockProvider, + updateState, + mockFetchFn, + ); + await stateMachine.executeStep(state); + expect(state.oauthStep).toBe("client_registration"); + + await stateMachine.executeStep(state); + + expect(registerClient).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + fetchFn: mockFetchFn, + }), + ); + }); + + it("should pass fetchFn to exchangeAuthorization when provided", async () => { + const { exchangeAuthorization } = + await import("@modelcontextprotocol/sdk/client/auth.js"); + const mockFetchFn = vi.fn(); + const metadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + vi.mocked(exchangeAuthorization).mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer", + }); + + const providerWithMetadata = { + ...mockProvider, + getServerMetadata: vi.fn(() => metadata), + } as unknown as BaseOAuthClientProvider; + + const tokenRequestState: AuthGuidedState = { + ...EMPTY_GUIDED_STATE, + oauthStep: "token_request", + oauthMetadata: metadata as OAuthMetadata, + oauthClientInfo: { client_id: "test-client" }, + authorizationCode: "test-code", + }; + + const stateMachine = new OAuthStateMachine( + serverUrl, + providerWithMetadata, + updateState, + mockFetchFn, + ); + await stateMachine.executeStep(tokenRequestState); + + expect(exchangeAuthorization).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + fetchFn: mockFetchFn, + }), + ); + }); + }); +}); diff --git a/core/__tests__/auth/storage-browser.test.ts b/core/__tests__/auth/storage-browser.test.ts new file mode 100644 index 000000000..ca4e79b2f --- /dev/null +++ b/core/__tests__/auth/storage-browser.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { BrowserOAuthStorage } from "../../auth/browser/storage.js"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Mock sessionStorage for Node.js environment +class MockSessionStorage implements Storage { + private storage: Map = new Map(); + + get length(): number { + return this.storage.size; + } + + key(index: number): string | null { + const keys = [...this.storage.keys()]; + return keys[index] ?? null; + } + + getItem(key: string): string | null { + return this.storage.get(key) || null; + } + + setItem(key: string, value: string): void { + this.storage.set(key, value); + } + + removeItem(key: string): void { + this.storage.delete(key); + } + + clear(): void { + this.storage.clear(); + } +} + +// Set up global sessionStorage mock +const mockSessionStorage = new MockSessionStorage(); +(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage = + mockSessionStorage; + +describe("BrowserOAuthStorage", () => { + let storage: BrowserOAuthStorage; + const testServerUrl = "http://localhost:3000"; + + beforeEach(() => { + storage = new BrowserOAuthStorage(); + mockSessionStorage.clear(); + }); + + afterEach(() => { + mockSessionStorage.clear(); + }); + + describe("getClientInformation", () => { + it("should return undefined when no client information is stored", async () => { + const result = await storage.getClientInformation(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored client information", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + client_secret: "test-secret", + }; + + storage.saveClientInformation(testServerUrl, clientInfo); + const result = await storage.getClientInformation(testServerUrl); + + expect(result).toEqual(clientInfo); + }); + + it("should return preregistered client information when requested", async () => { + const preregisteredInfo: OAuthClientInformation = { + client_id: "preregistered-id", + client_secret: "preregistered-secret", + }; + + // Use the storage API instead of manually setting sessionStorage + // since BrowserOAuthStorage now uses Zustand with a different storage format + storage.savePreregisteredClientInformation( + testServerUrl, + preregisteredInfo, + ); + + // Wait for Zustand to persist + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await storage.getClientInformation(testServerUrl, true); + + expect(result).toEqual(preregisteredInfo); + }); + }); + + describe("saveClientInformation", () => { + it("should save client information", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + + storage.saveClientInformation(testServerUrl, clientInfo); + const result = await storage.getClientInformation(testServerUrl); + + expect(result).toEqual(clientInfo); + }); + + it("should overwrite existing client information", async () => { + const firstInfo: OAuthClientInformation = { + client_id: "first-id", + }; + + const secondInfo: OAuthClientInformation = { + client_id: "second-id", + }; + + storage.saveClientInformation(testServerUrl, firstInfo); + storage.saveClientInformation(testServerUrl, secondInfo); + const result = await storage.getClientInformation(testServerUrl); + + expect(result).toEqual(secondInfo); + }); + }); + + describe("getTokens", () => { + it("should return undefined when no tokens are stored", async () => { + const result = await storage.getTokens(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored tokens", async () => { + const tokens: OAuthTokens = { + access_token: "test-access-token", + token_type: "Bearer", + expires_in: 3600, + }; + + storage.saveTokens(testServerUrl, tokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toEqual(tokens); + }); + }); + + describe("saveTokens", () => { + it("should save tokens", async () => { + const tokens: OAuthTokens = { + access_token: "test-access-token", + token_type: "Bearer", + }; + + storage.saveTokens(testServerUrl, tokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toEqual(tokens); + }); + }); + + describe("getCodeVerifier", () => { + it("should return undefined when no code verifier is stored", async () => { + const result = await storage.getCodeVerifier(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored code verifier", async () => { + const codeVerifier = "test-code-verifier"; + + storage.saveCodeVerifier(testServerUrl, codeVerifier); + const result = await storage.getCodeVerifier(testServerUrl); + + expect(result).toBe(codeVerifier); + }); + }); + + describe("saveCodeVerifier", () => { + it("should save code verifier", async () => { + const codeVerifier = "test-code-verifier"; + + storage.saveCodeVerifier(testServerUrl, codeVerifier); + const result = await storage.getCodeVerifier(testServerUrl); + + expect(result).toBe(codeVerifier); + }); + }); + + describe("getScope", () => { + it("should return undefined when no scope is stored", async () => { + const result = await storage.getScope(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored scope", async () => { + const scope = "read write"; + + storage.saveScope(testServerUrl, scope); + const result = await storage.getScope(testServerUrl); + + expect(result).toBe(scope); + }); + }); + + describe("saveScope", () => { + it("should save scope", async () => { + const scope = "read write"; + + storage.saveScope(testServerUrl, scope); + const result = await storage.getScope(testServerUrl); + + expect(result).toBe(scope); + }); + }); + + describe("getServerMetadata", () => { + it("should return null when no metadata is stored", async () => { + const result = await storage.getServerMetadata(testServerUrl); + expect(result).toBeNull(); + }); + + it("should return stored metadata", async () => { + const metadata: OAuthMetadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + + storage.saveServerMetadata(testServerUrl, metadata); + const result = await storage.getServerMetadata(testServerUrl); + + expect(result).toEqual(metadata); + }); + }); + + describe("saveServerMetadata", () => { + it("should save server metadata", async () => { + const metadata: OAuthMetadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + + storage.saveServerMetadata(testServerUrl, metadata); + const result = await storage.getServerMetadata(testServerUrl); + + expect(result).toEqual(metadata); + }); + }); + + describe("clearServerState", () => { + it("should clear all state for a server", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + const tokens: OAuthTokens = { + access_token: "test-token", + token_type: "Bearer", + }; + + storage.saveClientInformation(testServerUrl, clientInfo); + storage.saveTokens(testServerUrl, tokens); + + storage.clear(testServerUrl); + + expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); + expect(await storage.getTokens(testServerUrl)).toBeUndefined(); + }); + + it("should not affect state for other servers", async () => { + const otherServerUrl = "http://localhost:4000"; + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + + storage.saveClientInformation(testServerUrl, clientInfo); + storage.saveClientInformation(otherServerUrl, clientInfo); + + storage.clear(testServerUrl); + + expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); + expect(await storage.getClientInformation(otherServerUrl)).toEqual( + clientInfo, + ); + }); + }); + + describe("multiple servers", () => { + it("should store separate state for different servers", async () => { + const server1Url = "http://localhost:3000"; + const server2Url = "http://localhost:4000"; + + const clientInfo1: OAuthClientInformation = { + client_id: "client-1", + }; + + const clientInfo2: OAuthClientInformation = { + client_id: "client-2", + }; + + storage.saveClientInformation(server1Url, clientInfo1); + storage.saveClientInformation(server2Url, clientInfo2); + + expect(await storage.getClientInformation(server1Url)).toEqual( + clientInfo1, + ); + expect(await storage.getClientInformation(server2Url)).toEqual( + clientInfo2, + ); + }); + }); +}); diff --git a/core/__tests__/auth/storage-node.test.ts b/core/__tests__/auth/storage-node.test.ts new file mode 100644 index 000000000..f3fbb9fbb --- /dev/null +++ b/core/__tests__/auth/storage-node.test.ts @@ -0,0 +1,521 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + NodeOAuthStorage, + getOAuthStore, +} from "../../auth/node/storage-node.js"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { waitForStateFile } from "@modelcontextprotocol/inspector-test-server"; + +// Unique path per process so parallel test files don't share the same state file +const testStatePath = path.join( + os.tmpdir(), + `mcp-inspector-oauth-${process.pid}-storage-node.json`, +); + +describe("NodeOAuthStorage", () => { + let storage: NodeOAuthStorage; + const testServerUrl = "http://localhost:3000"; + const stateFilePath = testStatePath; + + beforeEach(async () => { + // Clean up any existing state file + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + + // Reset store state by clearing all servers + const store = getOAuthStore(testStatePath); + const state = store.getState(); + // Clear all server states + Object.keys(state.servers).forEach((url) => { + state.clearServerState(url); + }); + + storage = new NodeOAuthStorage(testStatePath); + }); + + afterEach(async () => { + // Clean up state file after each test + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + + // Reset store state + const store = getOAuthStore(testStatePath); + const state = store.getState(); + Object.keys(state.servers).forEach((url) => { + state.clearServerState(url); + }); + }); + + describe("getClientInformation", () => { + it("should return undefined when no client information is stored", async () => { + const result = await storage.getClientInformation(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored client information", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + client_secret: "test-secret", + }; + + await storage.saveClientInformation(testServerUrl, clientInfo); + + const result = await storage.getClientInformation(testServerUrl); + expect(result).toBeDefined(); + expect(result?.client_id).toBe(clientInfo.client_id); + expect(result?.client_secret).toBe(clientInfo.client_secret); + }); + + it("should return preregistered client information when requested", async () => { + const preregisteredInfo: OAuthClientInformation = { + client_id: "preregistered-id", + client_secret: "preregistered-secret", + }; + + // Store as preregistered by directly setting it in the store + const store = getOAuthStore(testStatePath); + store.getState().setServerState(testServerUrl, { + preregisteredClientInformation: preregisteredInfo, + }); + + const result = await storage.getClientInformation(testServerUrl, true); + + expect(result).toBeDefined(); + expect(result?.client_id).toBe(preregisteredInfo.client_id); + expect(result?.client_secret).toBe(preregisteredInfo.client_secret); + }); + }); + + describe("saveClientInformation", () => { + it("should save client information", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + + await storage.saveClientInformation(testServerUrl, clientInfo); + const result = await storage.getClientInformation(testServerUrl); + + expect(result).toBeDefined(); + expect(result?.client_id).toBe(clientInfo.client_id); + }); + + it("should overwrite existing client information", async () => { + const firstInfo: OAuthClientInformation = { + client_id: "first-id", + }; + + const secondInfo: OAuthClientInformation = { + client_id: "second-id", + }; + + storage.saveClientInformation(testServerUrl, firstInfo); + storage.saveClientInformation(testServerUrl, secondInfo); + const result = await storage.getClientInformation(testServerUrl); + + expect(result).toBeDefined(); + expect(result?.client_id).toBe(secondInfo.client_id); + }); + }); + + describe("getTokens", () => { + it("should return undefined when no tokens are stored", async () => { + const result = await storage.getTokens(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored tokens", async () => { + const tokens: OAuthTokens = { + access_token: "test-access-token", + token_type: "Bearer", + expires_in: 3600, + }; + + await storage.saveTokens(testServerUrl, tokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toEqual(tokens); + }); + + it("should persist and return refresh_token", async () => { + const tokens: OAuthTokens = { + access_token: "test-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "test-refresh-token", + }; + + await storage.saveTokens(testServerUrl, tokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toBeDefined(); + expect(result?.access_token).toBe(tokens.access_token); + expect(result?.refresh_token).toBe(tokens.refresh_token); + }); + }); + + describe("saveTokens", () => { + it("should save tokens", async () => { + const tokens: OAuthTokens = { + access_token: "test-access-token", + token_type: "Bearer", + }; + + await storage.saveTokens(testServerUrl, tokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toEqual(tokens); + }); + + it("should overwrite existing tokens", async () => { + const firstTokens: OAuthTokens = { + access_token: "first-token", + token_type: "Bearer", + }; + + const secondTokens: OAuthTokens = { + access_token: "second-token", + token_type: "Bearer", + }; + + await storage.saveTokens(testServerUrl, firstTokens); + await storage.saveTokens(testServerUrl, secondTokens); + const result = await storage.getTokens(testServerUrl); + + expect(result).toEqual(secondTokens); + }); + }); + + describe("getCodeVerifier", () => { + it("should return undefined when no code verifier is stored", async () => { + const result = await storage.getCodeVerifier(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored code verifier", async () => { + const codeVerifier = "test-code-verifier"; + + await storage.saveCodeVerifier(testServerUrl, codeVerifier); + const result = await storage.getCodeVerifier(testServerUrl); + + expect(result).toBe(codeVerifier); + }); + }); + + describe("saveCodeVerifier", () => { + it("should save code verifier", async () => { + const codeVerifier = "test-code-verifier"; + + await storage.saveCodeVerifier(testServerUrl, codeVerifier); + const result = await storage.getCodeVerifier(testServerUrl); + + expect(result).toBe(codeVerifier); + }); + }); + + describe("getScope", () => { + it("should return undefined when no scope is stored", async () => { + const result = await storage.getScope(testServerUrl); + expect(result).toBeUndefined(); + }); + + it("should return stored scope", async () => { + const scope = "read write"; + + await storage.saveScope(testServerUrl, scope); + const result = await storage.getScope(testServerUrl); + + expect(result).toBe(scope); + }); + }); + + describe("saveScope", () => { + it("should save scope", async () => { + const scope = "read write"; + + await storage.saveScope(testServerUrl, scope); + const result = await storage.getScope(testServerUrl); + + expect(result).toBe(scope); + }); + }); + + describe("getServerMetadata", () => { + it("should return null when no metadata is stored", async () => { + const result = await storage.getServerMetadata(testServerUrl); + expect(result).toBeNull(); + }); + + it("should return stored metadata", async () => { + const metadata: OAuthMetadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + + await storage.saveServerMetadata(testServerUrl, metadata); + const result = await storage.getServerMetadata(testServerUrl); + + expect(result).toEqual(metadata); + }); + }); + + describe("saveServerMetadata", () => { + it("should save server metadata", async () => { + const metadata: OAuthMetadata = { + issuer: "http://localhost:3000", + authorization_endpoint: "http://localhost:3000/authorize", + token_endpoint: "http://localhost:3000/token", + response_types_supported: ["code"], + }; + + await storage.saveServerMetadata(testServerUrl, metadata); + const result = await storage.getServerMetadata(testServerUrl); + + expect(result).toEqual(metadata); + }); + }); + + describe("clearServerState", () => { + it("should clear all state for a server", async () => { + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + const tokens: OAuthTokens = { + access_token: "test-token", + token_type: "Bearer", + }; + + await storage.saveClientInformation(testServerUrl, clientInfo); + await storage.saveTokens(testServerUrl, tokens); + + storage.clear(testServerUrl); + + expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); + expect(await storage.getTokens(testServerUrl)).toBeUndefined(); + }); + + it("should not affect state for other servers", async () => { + const otherServerUrl = "http://localhost:4000"; + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + + await storage.saveClientInformation(testServerUrl, clientInfo); + await storage.saveClientInformation(otherServerUrl, clientInfo); + + storage.clear(testServerUrl); + + expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); + const otherResult = await storage.getClientInformation(otherServerUrl); + expect(otherResult).toBeDefined(); + expect(otherResult?.client_id).toBe(clientInfo.client_id); + expect(otherResult).toEqual(clientInfo); + }); + }); + + describe("multiple servers", () => { + it("should store separate state for different servers", async () => { + const server1Url = "http://localhost:3000"; + const server2Url = "http://localhost:4000"; + + const clientInfo1: OAuthClientInformation = { + client_id: "client-1", + }; + + const clientInfo2: OAuthClientInformation = { + client_id: "client-2", + }; + + storage.saveClientInformation(server1Url, clientInfo1); + storage.saveClientInformation(server2Url, clientInfo2); + + const result1 = await storage.getClientInformation(server1Url); + const result2 = await storage.getClientInformation(server2Url); + expect(result1).toEqual(clientInfo1); + expect(result2).toEqual(clientInfo2); + }); + }); +}); + +describe("OAuth Store (Zustand)", () => { + const stateFilePath = testStatePath; + + beforeEach(async () => { + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + }); + + afterEach(async () => { + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + }); + + it("should create a new store", () => { + const store = getOAuthStore(testStatePath); + expect(store).toBeDefined(); + expect(store.getState).toBeDefined(); + expect(store.setState).toBeDefined(); + }); + + it("should return the same store instance via getOAuthStore", () => { + const store1 = getOAuthStore(testStatePath); + const store2 = getOAuthStore(testStatePath); + expect(store1).toBe(store2); + }); + + it("should persist state to file", async () => { + // Use a unique path so no other test (e.g. "should overwrite..." with second-id) can + // write to the same file; Zustand persist is async and can race with shared paths. + const persistTestPath = path.join( + os.tmpdir(), + `mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + try { + if (process.env.DEBUG_WAIT_FOR_STATE_FILE === "1") { + console.error("[storage-node.test] state file path:", persistTestPath); + } + const store = getOAuthStore(persistTestPath); + const serverUrl = "http://localhost:3000"; + const clientInfo: OAuthClientInformation = { + client_id: "test-client-id", + }; + + store.getState().setServerState(serverUrl, { + clientInformation: clientInfo, + }); + + type StateShape = { + state: { + servers: Record< + string, + { clientInformation?: OAuthClientInformation } + >; + }; + }; + const parsed = await waitForStateFile( + persistTestPath, + (p) => { + const s = (p as StateShape)?.state?.servers?.[serverUrl]; + return !!s?.clientInformation; + }, + { timeout: 2000, interval: 50 }, + ); + expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual( + clientInfo, + ); + } finally { + try { + await fs.unlink(persistTestPath); + } catch { + /* ignore */ + } + } + }); +}); + +describe("NodeOAuthStorage with custom storagePath", () => { + const testServerUrl = "http://localhost:3999"; + + it("should use custom path for state file", async () => { + const customPath = path.join( + os.tmpdir(), + `mcp-inspector-oauth-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + + try { + const storage = new NodeOAuthStorage(customPath); + const tokens: OAuthTokens = { + access_token: "custom-path-token", + token_type: "Bearer", + refresh_token: "custom-refresh", + }; + await storage.saveTokens(testServerUrl, tokens); + + type StateShape = { + state: { + servers: Record; + }; + }; + const parsed = await waitForStateFile( + customPath, + (p) => { + const t = (p as StateShape)?.state?.servers?.[testServerUrl]?.tokens; + return t?.access_token === tokens.access_token; + }, + { timeout: 2000, interval: 50 }, + ); + + expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe( + tokens.access_token, + ); + + const stored = await storage.getTokens(testServerUrl); + expect(stored?.access_token).toBe(tokens.access_token); + expect(stored?.refresh_token).toBe(tokens.refresh_token); + } finally { + try { + await fs.unlink(customPath); + } catch { + /* ignore */ + } + } + }); + + it("should isolate state from default store", async () => { + const customPath = path.join( + os.tmpdir(), + `mcp-inspector-oauth-isolate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + + try { + const defaultStore = getOAuthStore(); + defaultStore.getState().setServerState(testServerUrl, { + tokens: { + access_token: "default-token", + token_type: "Bearer", + }, + }); + + const customStorage = new NodeOAuthStorage(customPath); + await customStorage.saveTokens(testServerUrl, { + access_token: "custom-token", + token_type: "Bearer", + }); + + const fromCustom = await customStorage.getTokens(testServerUrl); + expect(fromCustom?.access_token).toBe("custom-token"); + + const defaultStorage = new NodeOAuthStorage(); + const fromDefault = await defaultStorage.getTokens(testServerUrl); + expect(fromDefault?.access_token).toBe("default-token"); + + defaultStore.getState().clearServerState(testServerUrl); + } finally { + try { + await fs.unlink(customPath); + } catch { + /* ignore */ + } + } + }); +}); diff --git a/core/__tests__/auth/utils.test.ts b/core/__tests__/auth/utils.test.ts new file mode 100644 index 000000000..bfc19d727 --- /dev/null +++ b/core/__tests__/auth/utils.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { + parseOAuthCallbackParams, + generateOAuthState, + generateOAuthStateWithMode, + parseOAuthState, + generateOAuthErrorDescription, +} from "../../auth/utils.js"; + +describe("OAuth Utils", () => { + describe("parseOAuthCallbackParams", () => { + it("should parse successful callback with code", () => { + const location = "?code=abc123&state=xyz789"; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(true); + if (result.successful) { + expect(result.code).toBe("abc123"); + } + }); + + it("should parse error callback", () => { + const location = + "?error=access_denied&error_description=User%20denied%20access"; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(false); + if (!result.successful) { + expect(result.error).toBe("access_denied"); + expect(result.error_description).toBe("User denied access"); + } + }); + + it("should parse error callback with error_uri", () => { + const location = + "?error=invalid_request&error_description=Invalid%20request&error_uri=https://example.com/error"; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(false); + if (!result.successful) { + expect(result.error).toBe("invalid_request"); + expect(result.error_description).toBe("Invalid request"); + expect(result.error_uri).toBe("https://example.com/error"); + } + }); + + it("should return invalid_request when neither code nor error is present", () => { + const location = "?state=xyz789"; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(false); + if (!result.successful) { + expect(result.error).toBe("invalid_request"); + expect(result.error_description).toBe( + "Missing code or error in response", + ); + } + }); + + it("should handle empty query string", () => { + const location = ""; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(false); + if (!result.successful) { + expect(result.error).toBe("invalid_request"); + } + }); + + it("should handle URL-encoded values", () => { + const location = "?code=abc%20123&error_description=Test%20%26%20More"; + const result = parseOAuthCallbackParams(location); + + expect(result.successful).toBe(true); + if (result.successful) { + expect(result.code).toBe("abc 123"); + } + }); + }); + + describe("generateOAuthState", () => { + it("should generate a random state string", () => { + const state1 = generateOAuthState(); + const state2 = generateOAuthState(); + + expect(typeof state1).toBe("string"); + expect(state1.length).toBeGreaterThan(0); + expect(state1).not.toBe(state2); // Should be different each time + }); + + it("should generate state with consistent length", () => { + const states = Array.from({ length: 10 }, () => generateOAuthState()); + const lengths = states.map((s) => s.length); + const uniqueLengths = new Set(lengths); + + // All states should have the same length (64 hex characters for 32 bytes) + expect(uniqueLengths.size).toBe(1); + expect(lengths[0]).toBe(64); + }); + + it("should generate valid hex string", () => { + const state = generateOAuthState(); + const hexPattern = /^[0-9a-f]+$/; + + expect(hexPattern.test(state)).toBe(true); + }); + }); + + describe("generateOAuthStateWithMode", () => { + it("should generate state with normal prefix", () => { + const state = generateOAuthStateWithMode("normal"); + expect(state.startsWith("normal:")).toBe(true); + expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/); + }); + + it("should generate state with guided prefix", () => { + const state = generateOAuthStateWithMode("guided"); + expect(state.startsWith("guided:")).toBe(true); + expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/); + }); + + it("should generate unique states", () => { + const s1 = generateOAuthStateWithMode("normal"); + const s2 = generateOAuthStateWithMode("normal"); + expect(s1).not.toBe(s2); + }); + }); + + describe("parseOAuthState", () => { + it("should parse normal prefix", () => { + const parsed = parseOAuthState("normal:abc123def456"); + expect(parsed).toEqual({ mode: "normal", authId: "abc123def456" }); + }); + + it("should parse guided prefix", () => { + const parsed = parseOAuthState("guided:a1b2c3d4e5f6"); + expect(parsed).toEqual({ mode: "guided", authId: "a1b2c3d4e5f6" }); + }); + + it("should parse legacy 64-char hex as normal", () => { + const hex = "a".repeat(64); + const parsed = parseOAuthState(hex); + expect(parsed).toEqual({ mode: "normal", authId: hex }); + }); + + it("should return null for invalid state", () => { + expect(parseOAuthState("")).toBeNull(); + expect(parseOAuthState("invalid")).toBeNull(); + expect(parseOAuthState("other:xyz")).toBeNull(); + }); + }); + + describe("generateOAuthErrorDescription", () => { + it("should generate error description with error code only", () => { + const params = { + successful: false as const, + error: "access_denied", + error_description: null, + error_uri: null, + }; + + const description = generateOAuthErrorDescription(params); + + expect(description).toBe("Error: access_denied."); + }); + + it("should generate error description with error code and description", () => { + const params = { + successful: false as const, + error: "invalid_request", + error_description: "The request is missing a required parameter", + error_uri: null, + }; + + const description = generateOAuthErrorDescription(params); + + expect(description).toContain("Error: invalid_request."); + expect(description).toContain( + "Details: The request is missing a required parameter.", + ); + }); + + it("should generate error description with all fields", () => { + const params = { + successful: false as const, + error: "server_error", + error_description: "An internal server error occurred", + error_uri: "https://example.com/errors/server_error", + }; + + const description = generateOAuthErrorDescription(params); + + expect(description).toContain("Error: server_error."); + expect(description).toContain( + "Details: An internal server error occurred.", + ); + expect(description).toContain( + "More info: https://example.com/errors/server_error.", + ); + }); + + it("should handle null error_description", () => { + const params = { + successful: false as const, + error: "access_denied", + error_description: null, + error_uri: "https://example.com/error", + }; + + const description = generateOAuthErrorDescription(params); + + expect(description).toContain("Error: access_denied."); + expect(description).not.toContain("Details:"); + expect(description).toContain("More info: https://example.com/error."); + }); + + it("should handle null error_uri", () => { + const params = { + successful: false as const, + error: "invalid_client", + error_description: "Invalid client credentials", + error_uri: null, + }; + + const description = generateOAuthErrorDescription(params); + + expect(description).toContain("Error: invalid_client."); + expect(description).toContain("Details: Invalid client credentials."); + expect(description).not.toContain("More info:"); + }); + }); +}); diff --git a/core/__tests__/helpers/oauth-client-fixtures.ts b/core/__tests__/helpers/oauth-client-fixtures.ts new file mode 100644 index 000000000..bd2b29d4e --- /dev/null +++ b/core/__tests__/helpers/oauth-client-fixtures.ts @@ -0,0 +1,172 @@ +/** + * OAuth client test fixtures for InspectorClient OAuth tests. + * These produce InspectorClient OAuth configuration and simulate OAuth flows. + */ + +import type { + OAuthNavigation, + RedirectUrlProvider, +} from "../../auth/providers.js"; +import type { OAuthStorage } from "../../auth/storage.js"; +import { ConsoleNavigation } from "../../auth/providers.js"; +import { NodeOAuthStorage } from "../../auth/node/storage-node.js"; + +/** Creates a static RedirectUrlProvider for tests. Single URL for both modes. */ +function createStaticRedirectUrlProvider( + redirectUrl: string, +): RedirectUrlProvider { + return { + getRedirectUrl: () => redirectUrl, + }; +} + +/** + * Creates OAuth configuration for InspectorClient tests + */ +export function createOAuthClientConfig(options: { + mode: "static" | "dcr" | "cimd"; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + redirectUrl: string; + scope?: string; +}): { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + redirectUrlProvider: RedirectUrlProvider; + scope?: string; + storage: OAuthStorage; + navigation: OAuthNavigation; +} { + const config: { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + redirectUrlProvider: RedirectUrlProvider; + scope?: string; + storage: OAuthStorage; + navigation: OAuthNavigation; + } = { + redirectUrlProvider: createStaticRedirectUrlProvider(options.redirectUrl), + storage: new NodeOAuthStorage(), + navigation: new ConsoleNavigation(), + }; + + if (options.mode === "static") { + if (!options.clientId) { + throw new Error("clientId is required for static mode"); + } + config.clientId = options.clientId; + if (options.clientSecret) { + config.clientSecret = options.clientSecret; + } + } else if (options.mode === "dcr") { + // DCR mode - no clientId needed, will be registered + if (options.clientId) { + config.clientId = options.clientId; + } + } else if (options.mode === "cimd") { + if (!options.clientMetadataUrl) { + throw new Error("clientMetadataUrl is required for CIMD mode"); + } + config.clientMetadataUrl = options.clientMetadataUrl; + } + + if (options.scope) { + config.scope = options.scope; + } + + return config; +} + +/** + * Client metadata document for CIMD testing + */ +export interface ClientMetadataDocument { + redirect_uris: string[]; + token_endpoint_auth_method?: string; + grant_types?: string[]; + response_types?: string[]; + client_name?: string; + client_uri?: string; + scope?: string; +} + +/** + * Creates an Express server that serves a client metadata document for CIMD testing + * The server runs on a different port and serves the metadata at the root path + * + * @param metadata - The client metadata document to serve + * @returns Object with server URL and cleanup function + */ +export async function createClientMetadataServer( + metadata: ClientMetadataDocument, +): Promise<{ url: string; stop: () => Promise }> { + const express = await import("express"); + const app = express.default(); + + app.get("/", (req, res) => { + res.json(metadata); + }); + + return new Promise((resolve, reject) => { + const server = app.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Failed to get server address")); + return; + } + const port = address.port; + const url = `http://localhost:${port}`; + + resolve({ + url, + stop: async () => { + return new Promise((resolveStop) => { + server.close(() => { + resolveStop(); + }); + }); + }, + }); + }); + + server.on("error", reject); + }); +} + +/** + * Helper function to programmatically complete OAuth authorization + * Makes HTTP GET request to authorization URL and extracts authorization code + * The test server's authorization endpoint auto-approves and redirects with code + * + * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event + * @returns Authorization code extracted from redirect URL + */ +export async function completeOAuthAuthorization( + authorizationUrl: URL, +): Promise { + const response = await fetch(authorizationUrl.toString(), { + redirect: "manual", + }); + + if (response.status !== 302 && response.status !== 301) { + throw new Error( + `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, + ); + } + + const redirectUrl = response.headers.get("location"); + if (!redirectUrl) { + throw new Error("No Location header in redirect response"); + } + + const redirectUrlObj = new URL(redirectUrl); + const code = redirectUrlObj.searchParams.get("code"); + if (!code) { + throw new Error(`No authorization code in redirect URL: ${redirectUrl}`); + } + + return code; +} diff --git a/core/__tests__/inspectorClient-oauth-e2e.test.ts b/core/__tests__/inspectorClient-oauth-e2e.test.ts new file mode 100644 index 000000000..6e716ff89 --- /dev/null +++ b/core/__tests__/inspectorClient-oauth-e2e.test.ts @@ -0,0 +1,1880 @@ +/** + * End-to-end OAuth tests for InspectorClient + * These tests require a test server with OAuth enabled + * Tests are parameterized to run against both SSE and streamable-http transports + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, + vi, +} from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { FetchRequestLogState } from "../mcp/state/index.js"; +import { createTransportNode } from "../mcp/node/transport.js"; +import { + TestServerHttp, + waitForStateFile, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, + getDCRRequests, + invalidateAccessToken, +} from "@modelcontextprotocol/inspector-test-server"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, + createClientMetadataServer, + type ClientMetadataDocument, +} from "./helpers/oauth-client-fixtures.js"; +import { + clearAllOAuthClientState, + NodeOAuthStorage, +} from "../auth/node/index.js"; +import type { InspectorClientOptions } from "../mcp/inspectorClient.js"; +import type { MCPServerConfig } from "../mcp/types.js"; + +const oauthTestStatePath = path.join( + os.tmpdir(), + `mcp-oauth-${process.pid}-inspectorClient-oauth-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +interface TransportConfig { + name: string; + serverType: "sse" | "streamable-http"; + clientType: "sse" | "streamable-http"; + endpoint: string; // "/sse" or "/mcp" +} + +const transports: TransportConfig[] = [ + { + name: "SSE", + serverType: "sse", + clientType: "sse", + endpoint: "/sse", + }, + { + name: "Streamable HTTP", + serverType: "streamable-http", + clientType: "streamable-http", + endpoint: "/mcp", + }, +]; + +describe("InspectorClient OAuth E2E", () => { + let server: TestServerHttp; + let client: InspectorClient; + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + afterAll(async () => { + try { + await fs.unlink(oauthTestStatePath); + } catch { + // Ignore if file does not exist or already removed + } + }); + + beforeEach(() => { + clearOAuthTestData(); + clearAllOAuthClientState(); + // Capture console.log output instead of printing to stdout during tests + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + if (client) { + await client.disconnect(); + } + if (server) { + await server.stop(); + } + // Restore console.log after each test + vi.restoreAllMocks(); + }); + + describe.each(transports)( + "Static/Preregistered Client Mode ($name)", + (transport) => { + it("should complete OAuth flow with static client", async () => { + const staticClientId = "test-static-client"; + const staticClientSecret = "test-static-secret"; + + // Create test server with OAuth enabled and static client + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + // Create client with static OAuth config + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + // Verify tokens are stored + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + + // Connection should now be successful + expect(client.getStatus()).toBe("connected"); + }); + + it("should complete OAuth flow with static client using authenticate() (normal mode)", async () => { + const staticClientId = "test-static-client-normal"; + const staticClientSecret = "test-static-secret-normal"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, // Needed for authenticate() to work + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // Use authenticate() (normal mode) - should use SDK's auth() + const authUrl = await client.authenticate(); + expect(authUrl.href).toContain("/oauth/authorize"); + + const stateAfterAuth = client.getOAuthState(); + expect(stateAfterAuth?.authType).toBe("normal"); + expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); + expect(stateAfterAuth?.authorizationUrl?.href).toBe(authUrl.href); + expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); + expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const stateAfterComplete = client.getOAuthState(); + expect(stateAfterComplete?.authType).toBe("normal"); + expect(stateAfterComplete?.oauthStep).toBe("complete"); + expect(stateAfterComplete?.oauthTokens).toBeDefined(); + expect(stateAfterComplete?.completedAt).toBeDefined(); + expect(typeof stateAfterComplete?.completedAt).toBe("number"); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + expect(client.getStatus()).toBe("connected"); + }); + + it("should retry original request after OAuth completion", async () => { + const staticClientId = "test-static-client-2"; + const staticClientSecret = "test-static-secret-2"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // Auth-provider flow: authenticate first, complete OAuth, then connect. + const authUrl = await client.authenticate(); + expect(authUrl.href).toContain("/oauth/authorize"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + const toolsResult = await client.listTools(); + expect(toolsResult).toBeDefined(); + }); + }, + ); + + describe.each(transports)( + "CIMD (Client ID Metadata Documents) Mode ($name)", + (transport) => { + let metadataServer: { url: string; stop: () => Promise } | null = + null; + + afterEach(async () => { + if (metadataServer) { + await metadataServer.stop(); + metadataServer = null; + } + }); + + it("should complete OAuth flow with CIMD client", async () => { + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + // Create client metadata document + const clientMetadata: ClientMetadataDocument = { + redirect_uris: [testRedirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector Test Client", + client_uri: "https://github.com/modelcontextprotocol/inspector", + scope: "mcp", + }; + + // Start metadata server + metadataServer = await createClientMetadataServer(clientMetadata); + const metadataUrl = metadataServer.url; + + // Create test server with OAuth enabled and CIMD support + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportCIMD: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + // Create client with CIMD config + const oauthConfig = createTestOAuthConfig({ + mode: "cimd", + clientMetadataUrl: metadataUrl, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // CIMD uses guided mode (HTTP clientMetadataUrl); auth() requires HTTPS + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + // Verify tokens are stored + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + + // Connection should now be successful + expect(client.getStatus()).toBe("connected"); + }); + + it("should retry original request after OAuth completion with CIMD", async () => { + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + const clientMetadata: ClientMetadataDocument = { + redirect_uris: [testRedirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector Test Client", + scope: "mcp", + }; + + metadataServer = await createClientMetadataServer(clientMetadata); + const metadataUrl = metadataServer.url; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportCIMD: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "cimd", + clientMetadataUrl: metadataUrl, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + const toolsResult = await client.listTools(); + expect(toolsResult).toBeDefined(); + }); + }, + ); + + describe.each(transports)( + "DCR (Dynamic Client Registration) Mode ($name)", + (transport) => { + it("should register client and complete OAuth flow", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + expect(authUrl.href).toContain("/oauth/authorize"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); + }); + + it("should register client and complete OAuth flow using authenticate() (normal mode)", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // Use authenticate() (normal mode) - should trigger DCR via SDK's auth() + const authUrl = await client.authenticate(); + expect(authUrl.href).toContain("/oauth/authorize"); + + const stateAfterAuth = client.getOAuthState(); + expect(stateAfterAuth?.authType).toBe("normal"); + expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); + expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); + expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const stateAfterComplete = client.getOAuthState(); + expect(stateAfterComplete?.authType).toBe("normal"); + expect(stateAfterComplete?.oauthStep).toBe("complete"); + expect(stateAfterComplete?.oauthTokens).toBeDefined(); + expect(stateAfterComplete?.completedAt).toBeDefined(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); + }); + + it("should register client and complete OAuth flow using runGuidedAuth() (automated guided mode)", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const stateAfterComplete = client.getOAuthState(); + expect(stateAfterComplete?.authType).toBe("guided"); + expect(stateAfterComplete?.oauthStep).toBe("complete"); + expect(stateAfterComplete?.completedAt).toBeDefined(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); + }); + + it("should complete OAuth flow using manual guided mode (beginGuidedAuth + proceedOAuthStep)", async () => { + const staticClientId = "test-static-manual"; + const staticClientSecret = "test-static-secret-manual"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + await client.beginGuidedAuth(); + + while (true) { + const state = client.getOAuthState(); + if ( + state?.oauthStep === "authorization_code" || + state?.oauthStep === "complete" + ) { + break; + } + await client.proceedOAuthStep(); + } + + const state = client.getOAuthState(); + const authUrl = state?.authorizationUrl; + if (!authUrl) throw new Error("Expected authorizationUrl"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const stateAfterComplete = client.getOAuthState(); + expect(stateAfterComplete?.authType).toBe("guided"); + expect(stateAfterComplete?.oauthStep).toBe("complete"); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); + }); + + it("should set authorization code without completing flow (completeFlow=false)", async () => { + const staticClientId = "test-static-set-code-false"; + const staticClientSecret = "test-static-secret-set-code-false"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // Start guided auth and progress to authorization_code step + await client.beginGuidedAuth(); + while (true) { + const state = client.getOAuthState(); + if (state?.oauthStep === "authorization_code") { + break; + } + await client.proceedOAuthStep(); + } + + const stateBefore = client.getOAuthState(); + expect(stateBefore?.oauthStep).toBe("authorization_code"); + expect(stateBefore?.authorizationCode).toBe(""); + + const authUrl = stateBefore?.authorizationUrl; + if (!authUrl) throw new Error("Expected authorizationUrl"); + const authCode = await completeOAuthAuthorization(authUrl); + + // Set code without completing flow + const stepEvents: Array<{ step: string; previousStep: string }> = []; + client.addEventListener("oauthStepChange", (event) => { + stepEvents.push({ + step: event.detail.step, + previousStep: event.detail.previousStep, + }); + }); + + await client.setGuidedAuthorizationCode(authCode, false); + + // Verify code was set but flow didn't complete + const stateAfter = client.getOAuthState(); + expect(stateAfter?.oauthStep).toBe("authorization_code"); + expect(stateAfter?.authorizationCode).toBe(authCode); + expect(stateAfter?.oauthTokens).toBeFalsy(); + + // Should have dispatched one event (code set, but step unchanged) + expect(stepEvents.length).toBe(1); + expect(stepEvents[0]?.step).toBe("authorization_code"); + expect(stepEvents[0]?.previousStep).toBe("authorization_code"); + + // Now manually proceed to complete + await client.proceedOAuthStep(); // authorization_code -> token_request + await client.proceedOAuthStep(); // token_request -> complete + + const finalState = client.getOAuthState(); + expect(finalState?.oauthStep).toBe("complete"); + expect(finalState?.oauthTokens).toBeDefined(); + }); + + it("should set authorization code and complete flow (completeFlow=true)", async () => { + const staticClientId = "test-static-set-code-true"; + const staticClientSecret = "test-static-secret-set-code-true"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + // Start guided auth and progress to authorization_code step + await client.beginGuidedAuth(); + while (true) { + const state = client.getOAuthState(); + if (state?.oauthStep === "authorization_code") { + break; + } + await client.proceedOAuthStep(); + } + + const stateBefore = client.getOAuthState(); + expect(stateBefore?.oauthStep).toBe("authorization_code"); + expect(stateBefore?.authorizationCode).toBe(""); + + const authUrl = stateBefore?.authorizationUrl; + if (!authUrl) throw new Error("Expected authorizationUrl"); + const authCode = await completeOAuthAuthorization(authUrl); + + // Set code with completeFlow=true (should auto-complete) + const stepEvents: Array<{ step: string; previousStep: string }> = []; + client.addEventListener("oauthStepChange", (event) => { + stepEvents.push({ + step: event.detail.step, + previousStep: event.detail.previousStep, + }); + }); + + await client.setGuidedAuthorizationCode(authCode, true); + + // Verify flow completed automatically + const stateAfter = client.getOAuthState(); + expect(stateAfter?.oauthStep).toBe("complete"); + expect(stateAfter?.authorizationCode).toBe(authCode); + expect(stateAfter?.oauthTokens).toBeDefined(); + + // Should have dispatched step change events for transitions (not for code setting) + // authorization_code -> token_request -> complete + expect(stepEvents.length).toBeGreaterThanOrEqual(2); + const lastEvent = stepEvents[stepEvents.length - 1]; + expect(lastEvent?.step).toBe("complete"); + }); + + it("runGuidedAuth continues from already-started guided flow", async () => { + const staticClientId = "test-run-from-started"; + const staticClientSecret = "test-secret-run-from-started"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + await client.beginGuidedAuth(); + await client.proceedOAuthStep(); + + const stateBeforeRun = client.getOAuthState(); + expect(stateBeforeRun?.oauthStep).not.toBe("authorization_code"); + expect(stateBeforeRun?.oauthStep).not.toBe("complete"); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); + }); + + it("runGuidedAuth returns undefined when already complete", async () => { + const staticClientId = "test-run-complete"; + const staticClientSecret = "test-secret-run-complete"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + + const stateAfterComplete = client.getOAuthState(); + expect(stateAfterComplete?.oauthStep).toBe("complete"); + + const authUrlAgain = await client.runGuidedAuth(); + expect(authUrlAgain).toBeUndefined(); + }); + }, + ); + + describe.each(transports)( + "Single redirect URL (DCR) ($name)", + (transport) => { + const redirectUrl = testRedirectUrl; + + it("should include single redirect_uri in DCR registration", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const dcr = getDCRRequests(); + expect(dcr.length).toBeGreaterThanOrEqual(1); + const uris = dcr[dcr.length - 1]!.redirect_uris; + expect(uris).toEqual([redirectUrl]); + }); + + it("should accept single redirect_uri for both normal and guided auth", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrlNormal = await client.authenticate(); + const authCodeNormal = await completeOAuthAuthorization(authUrlNormal); + await client.completeOAuthFlow(authCodeNormal); + await client.connect(); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + + const authUrlGuided = await client.runGuidedAuth(); + if (!authUrlGuided) throw new Error("Expected authorization URL"); + const authCodeGuided = await completeOAuthAuthorization(authUrlGuided); + await client.completeOAuthFlow(authCodeGuided); + await client.connect(); + expect(client.getStatus()).toBe("connected"); + }); + }, + ); + + describe.each(transports)("401 Error Handling ($name)", (transport) => { + it("should dispatch oauthAuthorizationRequired when authenticating", async () => { + const staticClientId = "test-client-401"; + const staticClientSecret = "test-secret-401"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + let authEventReceived = false; + client.addEventListener("oauthAuthorizationRequired", (event) => { + authEventReceived = true; + expect(event.detail.url).toBeInstanceOf(URL); + }); + + await client.authenticate(); + expect(authEventReceived).toBe(true); + }); + }); + + describe.each(transports)( + "Resource metadata discovery and oauthStepChange ($name)", + (transport) => { + it("should discover resource metadata and set resource in guided flow", async () => { + const staticClientId = "test-resource-metadata"; + const staticClientSecret = "test-secret-rm"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + await client.runGuidedAuth(); + + const state = client.getOAuthState(); + expect(state).toBeDefined(); + expect(state?.authType).toBe("guided"); + expect(state?.resourceMetadata).toBeDefined(); + expect(state?.resourceMetadata?.resource).toBeDefined(); + expect( + state?.resourceMetadata?.authorization_servers?.length, + ).toBeGreaterThanOrEqual(1); + expect(state?.resourceMetadata?.scopes_supported).toBeDefined(); + expect(state?.resource).toBeInstanceOf(URL); + expect(state?.resource?.href).toBe(state?.resourceMetadata?.resource); + expect(state?.resourceMetadataError).toBeNull(); + }); + + it("should dispatch oauthStepChange on each step transition in guided flow", async () => { + const staticClientId = "test-step-events"; + const staticClientSecret = "test-secret-se"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const stepEvents: Array<{ + step: string; + previousStep: string; + state: unknown; + }> = []; + client.addEventListener("oauthStepChange", (event) => { + stepEvents.push({ + step: event.detail.step, + previousStep: event.detail.previousStep, + state: event.detail.state, + }); + }); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + + const expectedTransitions = [ + { previousStep: "metadata_discovery", step: "client_registration" }, + { + previousStep: "client_registration", + step: "authorization_redirect", + }, + { + previousStep: "authorization_redirect", + step: "authorization_code", + }, + { previousStep: "authorization_code", step: "token_request" }, + { previousStep: "token_request", step: "complete" }, + ]; + + expect(stepEvents.length).toBe(expectedTransitions.length); + for (let i = 0; i < expectedTransitions.length; i++) { + const e = stepEvents[i]; + expect(e).toBeDefined(); + expect(e?.step).toBe(expectedTransitions[i]!.step); + expect(e?.previousStep).toBe(expectedTransitions[i]!.previousStep); + expect(e?.state).toBeDefined(); + expect(typeof e?.state === "object" && e?.state !== null).toBe(true); + } + + const finalState = client.getOAuthState(); + expect(finalState?.authType).toBe("guided"); + expect(finalState?.oauthStep).toBe("complete"); + expect(finalState?.oauthTokens).toBeDefined(); + expect(finalState?.completedAt).toBeDefined(); + expect(typeof finalState?.completedAt).toBe("number"); + }); + }, + ); + + describe.each(transports)( + "Token refresh (authProvider) ($name)", + (transport) => { + it("should persist refresh_token and succeed connect after 401 via refresh", async () => { + const staticClientId = "test-refresh"; + const staticClientSecret = "test-secret-refresh"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.refresh_token).toBeDefined(); + + invalidateAccessToken(tokens!.access_token); + + await client.disconnect(); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + const toolsResult = await client.listTools(); + expect(toolsResult).toBeDefined(); + }); + }, + ); + + describe.each(transports)("Token Management ($name)", (transport) => { + it("should store and retrieve OAuth tokens", async () => { + const staticClientId = "test-client-tokens"; + const staticClientSecret = "test-secret-tokens"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(await client.isOAuthAuthorized()).toBe(true); + + client.clearOAuthTokens(); + expect(await client.isOAuthAuthorized()).toBe(false); + expect(await client.getOAuthTokens()).toBeUndefined(); + }); + }); + + describe.each(transports)("Storage path (custom) ($name)", (transport) => { + it("should persist OAuth state to custom storagePath", async () => { + const customPath = path.join( + os.tmpdir(), + `mcp-inspector-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + + const staticClientId = "test-storage-path"; + const staticClientSecret = "test-secret-sp"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(customPath), + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + try { + const authUrl = await client.authenticate(); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + + type StateShape = { + state?: { + servers?: Record; + }; + }; + const parsed = await waitForStateFile( + customPath, + (p) => { + const servers = (p as StateShape)?.state?.servers ?? {}; + return Object.values(servers).some( + (s) => + !!(s as { tokens?: { access_token?: string } })?.tokens + ?.access_token, + ); + }, + { timeout: 2000, interval: 50 }, + ); + expect(Object.keys(parsed.state?.servers ?? {}).length).toBeGreaterThan( + 0, + ); + } finally { + try { + await fs.unlink(customPath); + } catch { + /* ignore */ + } + } + }); + }); + + describe("fetchFn integration", () => { + it("should use provided fetchFn for OAuth HTTP requests", async () => { + const tracker: Array<{ url: string; method: string }> = []; + const fetchFn: typeof fetch = ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + tracker.push({ + url: typeof input === "string" ? input : input.toString(), + method: init?.method ?? "GET", + }); + return fetch(input, init); + }; + + const staticClientId = "test-fetchFn-client"; + const staticClientSecret = "test-fetchFn-secret"; + const transport = transports[0]!; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + fetch: fetchFn, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + const fetchRequestLogState = new FetchRequestLogState(client); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + + expect(tracker.length).toBeGreaterThan(0); + const oauthUrls = tracker.filter( + (c) => + c.url.includes("well-known") || + c.url.includes("/oauth/") || + c.url.includes("token"), + ); + expect(oauthUrls.length).toBeGreaterThan(0); + + // Verify fetch tracking categories: auth vs transport + const fetchRequests = fetchRequestLogState.getFetchRequests(); + const authFetches = fetchRequests.filter((r) => r.category === "auth"); + const transportFetches = fetchRequests.filter( + (r) => r.category === "transport", + ); + expect(authFetches.length).toBeGreaterThan(0); + expect(transportFetches.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/core/__tests__/inspectorClient-oauth-fetchFn.test.ts b/core/__tests__/inspectorClient-oauth-fetchFn.test.ts new file mode 100644 index 000000000..1322d9aac --- /dev/null +++ b/core/__tests__/inspectorClient-oauth-fetchFn.test.ts @@ -0,0 +1,200 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + afterAll, +} from "vitest"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as fs from "node:fs/promises"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { createTransportNode } from "../mcp/node/transport.js"; +import type { MCPServerConfig } from "../mcp/types.js"; +import { NodeOAuthStorage } from "../auth/node/storage-node.js"; +import { createOAuthClientConfig } from "./helpers/oauth-client-fixtures.js"; +import type { InspectorClientOptions } from "../mcp/inspectorClient.js"; + +const oauthTestStatePath = path.join( + os.tmpdir(), + `mcp-oauth-${process.pid}-inspectorClient-oauth-fetchFn.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +const mockAuth = vi.fn(); +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: (...args: unknown[]) => mockAuth(...args), +})); + +describe("InspectorClient OAuth fetchFn", () => { + let client: InspectorClient; + + afterAll(async () => { + try { + await fs.unlink(oauthTestStatePath); + } catch { + // Ignore if file does not exist or already removed + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + + mockAuth.mockImplementation( + async (provider: { redirectToAuthorization: (url: URL) => void }) => { + provider.redirectToAuthorization( + new URL("http://example.com/oauth/authorize"), + ); + return "REDIRECT"; + }, + ); + }); + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + } + vi.restoreAllMocks(); + }); + + it("should pass fetchFn to auth() when provided", async () => { + const mockFetchFn = vi.fn(); + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-client", + redirectUrl: "http://localhost:3000/callback", + }); + + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + fetch: mockFetchFn, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + const url = await client.authenticate(); + + expect(url).toBeInstanceOf(URL); + expect(mockAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + fetchFn: expect.any(Function), + }), + ); + }); + + it("should pass fetchFn to auth() when not provided (uses default fetch)", async () => { + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-client", + redirectUrl: "http://localhost:3000/callback", + }); + + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + } as InspectorClientOptions, + ); + + await client.authenticate(); + + expect(mockAuth).toHaveBeenCalled(); + const callArgs = mockAuth.mock.calls[0]!; + const options = callArgs[1]; + expect(options).toHaveProperty("fetchFn"); + expect(typeof options.fetchFn).toBe("function"); + }); + + it("should pass fetchFn to auth() in completeOAuthFlow when provided", async () => { + const mockFetchFn = vi.fn(); + mockAuth.mockImplementation( + async (provider: { saveTokens: (tokens: unknown) => void }) => { + provider.saveTokens({ + access_token: "test-token", + token_type: "Bearer", + }); + return "AUTHORIZED"; + }, + ); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-client", + redirectUrl: "http://localhost:3000/callback", + }); + + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + fetch: mockFetchFn, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + await client.completeOAuthFlow("test-authorization-code"); + + expect(mockAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + authorizationCode: "test-authorization-code", + fetchFn: expect.any(Function), + }), + ); + }); +}); diff --git a/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts b/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts new file mode 100644 index 000000000..fe09ff544 --- /dev/null +++ b/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -0,0 +1,506 @@ +/** + * End-to-end OAuth tests for InspectorClient using RemoteOAuthStorage. + * Tests OAuth flows with remote storage (HTTP API) instead of file storage. + * These tests verify that OAuth state persists correctly via the remote storage API. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { createRemoteTransport } from "../mcp/remote/createRemoteTransport.js"; +import { createRemoteFetch } from "../mcp/remote/createRemoteFetch.js"; +import { RemoteOAuthStorage } from "../auth/remote/storage-remote.js"; +import { NodeOAuthStorage } from "../auth/node/storage-node.js"; +import { createRemoteApp } from "../mcp/remote/node/server.js"; +import { + TestServerHttp, + waitForOAuthWellKnown, + waitForRemoteStore, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, +} from "@modelcontextprotocol/inspector-test-server"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "./helpers/oauth-client-fixtures.js"; +import { ConsoleNavigation } from "../auth/providers.js"; +import type { InspectorClientOptions } from "../mcp/inspectorClient.js"; +import type { MCPServerConfig } from "../mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-inspectorClient-oauth-remote-storage-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +interface TransportConfig { + name: string; + serverType: "sse" | "streamable-http"; + clientType: "sse" | "streamable-http"; + endpoint: string; +} + +const transports: TransportConfig[] = [ + { + name: "SSE", + serverType: "sse", + clientType: "sse", + endpoint: "/sse", + }, + { + name: "Streamable HTTP", + serverType: "streamable-http", + clientType: "streamable-http", + endpoint: "/mcp", + }, +]; + +interface StartRemoteServerOptions { + storageDir?: string; +} + +async function startRemoteServer( + port: number, + options: StartRemoteServerOptions = {}, +): Promise<{ + baseUrl: string; + server: ServerType; + authToken: string; +}> { + const { app, authToken } = createRemoteApp({ + storageDir: options.storageDir, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port, hostname: "127.0.0.1" }, + (info) => { + const actualPort = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : port; + resolve({ + baseUrl: `http://127.0.0.1:${actualPort}`, + server, + authToken, + }); + }, + ); + server.on("error", reject); + }); +} + +describe("InspectorClient OAuth E2E with Remote Storage", () => { + let mcpServer: TestServerHttp; + let remoteServer: ServerType | null = null; + let remoteBaseUrl: string | null = null; + let remoteAuthToken: string | null = null; + let client: InspectorClient; + let tempDir: string | null = null; + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + tempDir = mkdtempSync(join(tmpdir(), "inspector-remote-storage-test-")); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterAll(() => { + try { + unlinkSync(oauthTestStatePath); + } catch { + // Ignore if file does not exist or already removed + } + }); + + afterEach(async () => { + if (client) { + await client.disconnect(); + } + if (mcpServer) { + await mcpServer.stop(); + } + if (remoteServer) { + await new Promise((resolve, reject) => { + remoteServer!.close((err) => (err ? reject(err) : resolve())); + }); + remoteServer = null; + } + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + vi.restoreAllMocks(); + }); + + async function setupRemoteServer(): Promise { + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir!, + }); + remoteServer = server; + remoteBaseUrl = baseUrl; + remoteAuthToken = authToken; + } + + describe.each(transports)( + "Static/Preregistered Client Mode ($name)", + (transport) => { + it("should complete OAuth flow with static client using remote storage", async () => { + await setupRemoteServer(); + + const staticClientId = "test-static-client"; + const staticClientSecret = "test-static-secret"; + + // Create test server with OAuth enabled and static client + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + // Create client with remote transport and remote OAuth storage + const createTransport = createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteFetch = createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteStorage = new RemoteOAuthStorage({ + baseUrl: remoteBaseUrl!, + storeId: "oauth", + authToken: remoteAuthToken!, + }); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransport, + fetch: remoteFetch, + oauth: { + storage: remoteStorage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + // Verify tokens are stored + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + + // Connection should now be successful + expect(client.getStatus()).toBe("connected"); + }); + + it("should persist OAuth state and reload on new client instance", async () => { + await setupRemoteServer(); + + const staticClientId = "test-static-client-reload"; + const staticClientSecret = "test-static-secret-reload"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const createTransport = createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteFetch = createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteStorage = new RemoteOAuthStorage({ + baseUrl: remoteBaseUrl!, + storeId: "oauth", + authToken: remoteAuthToken!, + }); + + // First client: complete OAuth flow + const oauthConfig1 = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig1: InspectorClientOptions = { + environment: { + transport: createTransport, + fetch: remoteFetch, + oauth: { + storage: remoteStorage, + navigation: oauthConfig1.navigation, + redirectUrlProvider: oauthConfig1.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig1.clientId, + clientSecret: oauthConfig1.clientSecret, + clientMetadataUrl: oauthConfig1.clientMetadataUrl, + scope: oauthConfig1.scope, + }, + }; + + const client1 = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig1, + ); + + const authUrl = await client1.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + const authCode = await completeOAuthAuthorization(authUrl); + await client1.completeOAuthFlow(authCode); + await client1.connect(); + + const tokens1 = await client1.getOAuthTokens(); + expect(tokens1).toBeDefined(); + await client1.disconnect(); + + // Wait until remote server has persisted state before creating second client + await waitForRemoteStore( + remoteBaseUrl!, + "oauth", + remoteAuthToken!, + (body) => { + const b = body as { + state?: { + servers?: Record< + string, + { tokens?: { access_token?: string } } + >; + }; + }; + return !!( + b?.state?.servers && + Object.values(b.state.servers).some( + (s) => s?.tokens?.access_token, + ) + ); + }, + ); + + // Second client: should load persisted state + const remoteStorage2 = new RemoteOAuthStorage({ + baseUrl: remoteBaseUrl!, + storeId: "oauth", + authToken: remoteAuthToken!, + }); + + const oauthConfig2 = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig2: InspectorClientOptions = { + environment: { + transport: createTransport, + fetch: remoteFetch, + oauth: { + storage: remoteStorage2, + navigation: oauthConfig2.navigation, + redirectUrlProvider: oauthConfig2.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig2.clientId, + clientSecret: oauthConfig2.clientSecret, + clientMetadataUrl: oauthConfig2.clientMetadataUrl, + scope: oauthConfig2.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig2, + ); + + // Wait for storage to hydrate and tokens to be available + await vi.waitFor( + async () => { + const tokens = await client.getOAuthTokens(); + if (!tokens) { + throw new Error("Tokens not yet loaded from storage"); + } + return tokens; + }, + { timeout: 2000, interval: 50 }, + ); + + // Should be able to connect without re-authenticating + await client.connect(); + expect(client.getStatus()).toBe("connected"); + + // Tokens should be loaded from remote storage + const tokens2 = await client.getOAuthTokens(); + expect(tokens2).toBeDefined(); + expect(tokens2?.access_token).toBe(tokens1?.access_token); + }); + }, + ); + + describe.each(transports)( + "DCR (Dynamic Client Registration) Mode ($name)", + (transport) => { + it("should register client and complete OAuth flow using remote storage", async () => { + await setupRemoteServer(); + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const createTransport = createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteFetch = createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }); + const remoteStorage = new RemoteOAuthStorage({ + baseUrl: remoteBaseUrl!, + storeId: "oauth", + authToken: remoteAuthToken!, + }); + + const oauthConfig = createTestOAuthConfig({ + mode: "dcr", + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransport, + fetch: remoteFetch, + oauth: { + storage: remoteStorage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.runGuidedAuth(); + if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); + await client.connect(); + + // Verify tokens are stored + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + + expect(client.getStatus()).toBe("connected"); + }); + }, + ); +}); diff --git a/core/__tests__/inspectorClient-oauth.test.ts b/core/__tests__/inspectorClient-oauth.test.ts new file mode 100644 index 000000000..ac2473d62 --- /dev/null +++ b/core/__tests__/inspectorClient-oauth.test.ts @@ -0,0 +1,553 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, + vi, +} from "vitest"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as fs from "node:fs/promises"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { FetchRequestLogState } from "../mcp/state/index.js"; +import { createTransportNode } from "../mcp/node/transport.js"; +import type { MCPServerConfig } from "../mcp/types.js"; +import { NodeOAuthStorage } from "../auth/node/storage-node.js"; +import { + TestServerHttp, + waitForEvent, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, +} from "@modelcontextprotocol/inspector-test-server"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "./helpers/oauth-client-fixtures.js"; +import type { InspectorClientOptions } from "../mcp/inspectorClient.js"; + +const oauthTestStatePath = path.join( + os.tmpdir(), + `mcp-oauth-${process.pid}-inspectorClient-oauth.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +describe("InspectorClient OAuth", () => { + let client: InspectorClient; + + afterAll(async () => { + try { + await fs.unlink(oauthTestStatePath); + } catch { + // Ignore if file does not exist or already removed + } + }); + + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + // Create client with HTTP transport (OAuth only works with HTTP transports) + const config: MCPServerConfig = { + type: "sse", + url: "http://localhost:3000/sse", + }; + client = new InspectorClient(config, { + environment: { transport: createTransportNode }, + }); + }); + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + } + vi.restoreAllMocks(); + }); + + describe("OAuth Configuration", () => { + it("should set OAuth configuration", () => { + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-client-id", + clientSecret: "test-secret", + redirectUrl: "http://localhost:3000/callback", + scope: "read write", + }); + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" }, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + // Configuration should be set (no error thrown) + expect(client).toBeDefined(); + }); + + it("should set OAuth configuration with clientMetadataUrl for CIMD", () => { + const oauthConfig = createTestOAuthConfig({ + mode: "cimd", + clientMetadataUrl: "https://example.com/client-metadata.json", + redirectUrl: "http://localhost:3000/callback", + scope: "read write", + }); + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" }, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + expect(client).toBeDefined(); + }); + }); + + describe("OAuth Token Management", () => { + beforeEach(() => { + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-client-id", + redirectUrl: "http://localhost:3000/callback", + }); + client = new InspectorClient( + { type: "sse", url: "http://localhost:3000/sse" }, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + }); + + it("should return undefined tokens when not authorized", async () => { + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeUndefined(); + }); + + it("should clear OAuth tokens", () => { + client.clearOAuthTokens(); + // Should not throw + expect(client).toBeDefined(); + }); + + it("should return false for isOAuthAuthorized when not authorized", async () => { + const isAuthorized = await client.isOAuthAuthorized(); + expect(isAuthorized).toBe(false); + }); + }); + + describe("OAuth fetch tracking", () => { + let testServer: TestServerHttp; + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (testServer) { + await testServer.stop(); + } + }); + + it("should track auth fetches with category 'auth' during guided auth", async () => { + const staticClientId = "test-auth-fetch-client"; + const staticClientSecret = "test-auth-fetch-secret"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "sse" as const, + ...createOAuthTestServerConfig({ + requireAuth: false, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + testServer = new TestServerHttp(serverConfig); + const port = await testServer.start(); + const serverUrl = `http://localhost:${port}`; + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const testClient = new InspectorClient( + { + type: "sse", + url: `${serverUrl}/sse`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + const fetchRequestLogState = new FetchRequestLogState(testClient); + // beginGuidedAuth runs metadata_discovery, client_registration, authorization_redirect + // (stops at authorization_code awaiting user). Produces auth fetches only (no connect yet). + await testClient.beginGuidedAuth(); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + const authFetches = fetchRequests.filter( + (req) => req.category === "auth", + ); + expect(authFetches.length).toBeGreaterThan(0); + const hasOAuthUrls = authFetches.some( + (req) => + req.url.includes("well-known") || + req.url.includes("/oauth/") || + req.url.includes("token"), + ); + expect(hasOAuthUrls).toBe(true); + + fetchRequestLogState.destroy(); + await testClient.disconnect(); + }); + }); + + describe("OAuth Events", () => { + let testServer: TestServerHttp; + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (testServer) { + await testServer.stop(); + } + }); + + it("should dispatch oauthAuthorizationRequired event", async () => { + const staticClientId = "test-event-client"; + const staticClientSecret = "test-event-secret"; + + // Create test server with OAuth enabled and DCR support (for authenticate() normal mode) + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "sse" as const, + ...createOAuthTestServerConfig({ + requireAuth: false, // Don't require auth for this test + supportDCR: true, // Enable DCR so authenticate() can work + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + testServer = new TestServerHttp(serverConfig); + const port = await testServer.start(); + const serverUrl = `http://localhost:${port}`; + + // Create client with OAuth config pointing to test server + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const testClient = new InspectorClient( + { + type: "sse", + url: `${serverUrl}/sse`, + } as MCPServerConfig, + clientConfig, + ); + + testClient.authenticate().catch(() => {}); + + const detail = await waitForEvent<{ url: URL }>( + testClient, + "oauthAuthorizationRequired", + { timeout: 5000 }, + ); + expect(detail).toHaveProperty("url"); + expect(detail.url).toBeInstanceOf(URL); + expect(detail.url.href).toContain("/oauth/authorize"); + await testClient.disconnect(); + }); + + it("should dispatch oauthError event when OAuth flow fails", async () => { + // Create a minimal test server just for metadata discovery + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "sse" as const, + ...createOAuthTestServerConfig({ + requireAuth: false, + supportDCR: true, + }), + }; + + testServer = new TestServerHttp(serverConfig); + const port = await testServer.start(); + const serverUrl = `http://localhost:${port}`; + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: "test-error-client", + clientSecret: "test-error-secret", + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const testClient = new InspectorClient( + { + type: "sse", + url: `${serverUrl}/sse`, + } as MCPServerConfig, + clientConfig, + ); + + testClient.completeOAuthFlow("invalid-test-code").catch(() => {}); + + const detail = await waitForEvent<{ error: Error }>( + testClient, + "oauthError", + { + timeout: 3000, + }, + ); + expect(detail).toHaveProperty("error"); + expect(detail.error).toBeInstanceOf(Error); + await testClient.disconnect(); + }); + }); + + describe("Token Injection in HTTP Transports", () => { + let testServer: TestServerHttp; + const testRedirectUrl = "http://localhost:3001/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (testServer) { + await testServer.stop(); + } + }); + + it("should inject Bearer token in HTTP requests when OAuth is configured", async () => { + const staticClientId = "test-token-injection-client"; + const staticClientSecret = "test-token-injection-secret"; + + // Create test server with OAuth enabled and auth required + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "sse" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + testServer = new TestServerHttp(serverConfig); + const port = await testServer.start(); + const serverUrl = `http://localhost:${port}`; + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const testClient = new InspectorClient( + { + type: "sse", + url: `${serverUrl}/sse`, + } as MCPServerConfig, + clientConfig, + ); + const fetchRequestLogState = new FetchRequestLogState(testClient); + + // Auth-provider flow: authenticate first, complete OAuth, then connect. + // connect() creates transport with authProvider; tokens are already in storage. + const authorizationUrl = await testClient.authenticate(); + const authCode = await completeOAuthAuthorization(authorizationUrl); + await testClient.completeOAuthFlow(authCode); + + await testClient.connect(); + + const tokens = await testClient.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + + // listTools() succeeds only if authProvider injects Bearer token + const toolsResult = await testClient.listTools(); + expect(toolsResult).toBeDefined(); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + + // Auth fetches (discovery, token exchange) should have category 'auth' + const authFetches = fetchRequests.filter( + (req) => req.category === "auth", + ); + expect(authFetches.length).toBeGreaterThan(0); + const oauthFetches = authFetches.filter( + (req) => + req.url.includes("well-known") || + req.url.includes("/oauth/") || + req.url.includes("/token"), + ); + expect(oauthFetches.length).toBeGreaterThan(0); + + // Transport fetches (SSE, MCP) should have category 'transport' + const transportFetches = fetchRequests.filter( + (req) => req.category === "transport", + ); + expect(transportFetches.length).toBeGreaterThan(0); + + const mcpPostRequests = transportFetches.filter( + (req) => + req.method === "POST" && + (req.url.includes("/sse") || req.url.includes("/mcp")) && + !req.url.includes("/oauth"), + ); + if (mcpPostRequests.length > 0) { + const hasAuthHeader = mcpPostRequests.some((req) => { + const authHeader = + req.requestHeaders?.["Authorization"] || + req.requestHeaders?.["authorization"]; + return authHeader && authHeader.startsWith("Bearer "); + }); + if (hasAuthHeader) { + expect(hasAuthHeader).toBe(true); + } + } + + await testClient.disconnect(); + }); + }); +}); diff --git a/core/__tests__/inspectorClient.test.ts b/core/__tests__/inspectorClient.test.ts new file mode 100644 index 000000000..0a0820788 --- /dev/null +++ b/core/__tests__/inspectorClient.test.ts @@ -0,0 +1,4005 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as z from "zod/v4"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { + MessageLogState, + FetchRequestLogState, + StderrLogState, + PagedResourcesState, + PagedResourceTemplatesState, + PagedPromptsState, + ManagedResourcesState, + ManagedPromptsState, +} from "../mcp/state/index.js"; +import { createTransportNode } from "../mcp/node/transport.js"; +import { SamplingCreateMessage } from "../mcp/samplingCreateMessage.js"; +import { ElicitationCreateMessage } from "../mcp/elicitationCreateMessage.js"; +import { + getTestMcpServerCommand, + createTestServerHttp, + type TestServerHttp, + waitForEvent, + waitForProgressCount, + createEchoTool, + createTestServerInfo, + createFileResourceTemplate, + createCollectSampleTool, + createCollectFormElicitationTool, + createCollectUrlElicitationTool, + createUrlElicitationFormTool, + createSendNotificationTool, + createListRootsTool, + createArgsPrompt, + createNumberedTools, + createNumberedResources, + createNumberedResourceTemplates, + createNumberedPrompts, + getTaskServerConfig, + createElicitationTaskTool, + createSamplingTaskTool, + createProgressTaskTool, + createTaskTool, +} from "@modelcontextprotocol/inspector-test-server"; +import type { + MessageEntry, + ConnectionStatus, + FetchRequestEntryBase, +} from "../mcp/types.js"; +import type { JsonValue } from "../json/jsonUtils.js"; +import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js"; +import type { + CreateMessageResult, + ElicitResult, + CallToolResult, + Task, + Tool, + Resource, + ResourceTemplate, + Prompt, + Progress, + ContentBlock, +} from "@modelcontextprotocol/sdk/types.js"; +import { + RELATED_TASK_META_KEY, + McpError, + ErrorCode, +} from "@modelcontextprotocol/sdk/types.js"; + +/** Get all tools from the client via listTools() (paginates if needed). */ +async function getAllTools(client: InspectorClient): Promise { + const collected: Tool[] = []; + let cursor: string | undefined; + for (let i = 0; i < 100; i++) { + const r = await client.listTools(cursor); + collected.push(...r.tools); + cursor = r.nextCursor; + if (!cursor) break; + } + return collected; +} + +/** Get a tool by name from the client via listTools() (paginates if needed). */ +async function getTool(client: InspectorClient, name: string): Promise { + const tool = (await getAllTools(client)).find((t) => t.name === name); + if (tool) return tool; + throw new Error(`Tool ${name} not found`); +} + +/** Get all resources from the client via listResources() (paginates if needed). */ +async function getAllResources( + client: InspectorClient, + metadata?: Record, +): Promise { + const collected: Resource[] = []; + let cursor: string | undefined; + for (let i = 0; i < 100; i++) { + const r = await client.listResources(cursor, metadata); + collected.push(...r.resources); + cursor = r.nextCursor; + if (!cursor) break; + } + return collected; +} + +/** Get all resource templates via listResourceTemplates() (paginates if needed). */ +async function getAllResourceTemplates( + client: InspectorClient, + metadata?: Record, +): Promise { + const collected: ResourceTemplate[] = []; + let cursor: string | undefined; + for (let i = 0; i < 100; i++) { + const r = await client.listResourceTemplates(cursor, metadata); + collected.push(...r.resourceTemplates); + cursor = r.nextCursor; + if (!cursor) break; + } + return collected; +} + +/** Get all prompts via listPrompts() (paginates if needed). */ +async function getAllPrompts( + client: InspectorClient, + metadata?: Record, +): Promise { + const collected: Prompt[] = []; + let cursor: string | undefined; + for (let i = 0; i < 100; i++) { + const r = await client.listPrompts(cursor, metadata); + collected.push(...r.prompts); + cursor = r.nextCursor; + if (!cursor) break; + } + return collected; +} + +/** Minimal Tool shape for tests that need to call a tool by name (e.g. server returns "not found"). */ +function minimalTool(name: string): Tool { + return { name, description: "", inputSchema: {} }; +} + +describe("InspectorClient", () => { + let client: InspectorClient | null; + let server: TestServerHttp | null; + let serverCommand: { command: string; args: string[] }; + + beforeEach(() => { + serverCommand = getTestMcpServerCommand(); + server = null; + }); + + afterEach(async () => { + // Orderly teardown: disconnect client first, then stop server. + // HTTP test server sets closing before close so in-flight progress tools skip sending. + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // Ignore server stop errors + } + server = null; + } + }); + + describe("Connection Management", () => { + it("should create client with stdio transport", () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode } }, + ); + + expect(client.getStatus()).toBe("disconnected"); + expect(client.getServerType()).toBe("stdio"); + }); + + it("should connect to server", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + }); + + it("should disconnect from server", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + expect(client.getStatus()).toBe("disconnected"); + }); + + it("should clear server state on disconnect", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + const pagedResourcesState = new PagedResourcesState(client); + const pagedPromptsState = new PagedPromptsState(client); + + await client.connect(); + expect((await client.listTools()).tools.length).toBeGreaterThan(0); + await pagedResourcesState.loadPage(); + await pagedPromptsState.loadPage(); + expect(pagedResourcesState.getResources().length).toBeGreaterThan(0); + expect(pagedPromptsState.getPrompts().length).toBeGreaterThan(0); + + await client.disconnect(); + expect(pagedResourcesState.getResources().length).toBe(0); + expect(pagedPromptsState.getPrompts().length).toBe(0); + + pagedResourcesState.destroy(); + pagedPromptsState.destroy(); + }); + + it("MessageLogState clears on connect when attached to client", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + const messageLogState = new MessageLogState(client); + await client.connect(); + await getAllTools(client); + const firstConnectMessages = messageLogState.getMessages(); + expect(firstConnectMessages.length).toBeGreaterThan(0); + + await client.disconnect(); + await client.connect(); + await getAllTools(client); + const secondConnectMessages = messageLogState.getMessages(); + expect(secondConnectMessages.length).toBeGreaterThan(0); + if (firstConnectMessages.length > 0 && secondConnectMessages.length > 0) { + const lastFirstMessage = + firstConnectMessages[firstConnectMessages.length - 1]; + const firstSecondMessage = secondConnectMessages[0]; + if (lastFirstMessage && firstSecondMessage) { + expect(firstSecondMessage.timestamp.getTime()).toBeGreaterThanOrEqual( + lastFirstMessage.timestamp.getTime(), + ); + } + } + messageLogState.destroy(); + }); + }); + + describe("Message Tracking", () => { + it("should track requests (via MessageLogState)", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + const messageLogState = new MessageLogState(client); + await client.connect(); + await getAllTools(client); + + const messages = messageLogState.getMessages(); + expect(messages.length).toBeGreaterThan(0); + const request = messages.find((m) => m.direction === "request"); + expect(request).toBeDefined(); + if (request) { + expect("method" in request.message).toBe(true); + } + messageLogState.destroy(); + }); + + it("should track responses (via MessageLogState)", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + const messageLogState = new MessageLogState(client); + await client.connect(); + await getAllTools(client); + + const messages = messageLogState.getMessages(); + const request = messages.find((m) => m.direction === "request"); + expect(request).toBeDefined(); + if (request && "response" in request) { + expect(request.response).toBeDefined(); + expect(request.duration).toBeDefined(); + } + messageLogState.destroy(); + }); + + it("should emit message events", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const messageEvents: MessageEntry[] = []; + client.addEventListener("message", (event) => { + messageEvents.push(event.detail); + }); + + await client.connect(); + await getAllTools(client); + + expect(messageEvents.length).toBeGreaterThan(0); + }); + + it("MessageLogState getMessages(predicate) returns only matching entries", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + const messageLogState = new MessageLogState(client); + await client.connect(); + await getAllTools(client); + + const all = messageLogState.getMessages(); + expect(all.length).toBeGreaterThan(0); + + const requests = messageLogState.getMessages( + (m) => m.direction === "request", + ); + expect(requests.length).toBeLessThanOrEqual(all.length); + expect(requests.every((m) => m.direction === "request")).toBe(true); + + const notifications = messageLogState.getMessages( + (m) => m.direction === "notification", + ); + expect(notifications.every((m) => m.direction === "notification")).toBe( + true, + ); + messageLogState.destroy(); + }); + }); + + describe("Fetch Request Tracking", () => { + it("should track HTTP requests for SSE transport", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const fetchRequestLogState = new FetchRequestLogState(client); + await client.connect(); + await getAllTools(client); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + const request = fetchRequests[0]; + expect(request).toBeDefined(); + if (request) { + expect(request.url).toContain("/sse"); + expect(request.method).toBe("GET"); + expect(request.category).toBe("transport"); + } + fetchRequestLogState.destroy(); + }); + + it("should track HTTP requests for streamable-http transport", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + await server.start(); + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const fetchRequestLogState = new FetchRequestLogState(client); + await client.connect(); + await getAllTools(client); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + const request = fetchRequests[0]; + expect(request).toBeDefined(); + if (request) { + expect(request.url).toContain("/mcp"); + expect(request.method).toBe("POST"); + expect(request.category).toBe("transport"); + } + fetchRequestLogState.destroy(); + }); + + it("should track request and response details", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + await server.start(); + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const fetchRequestLogState = new FetchRequestLogState(client); + await client.connect(); + await getAllTools(client); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + const request = fetchRequests.find((r) => r.responseStatus !== undefined); + expect(request).toBeDefined(); + if (request) { + expect(request.requestHeaders).toBeDefined(); + expect(request.responseStatus).toBeDefined(); + expect(request.responseHeaders).toBeDefined(); + expect(request.duration).toBeDefined(); + expect(request.category).toBe("transport"); + } + fetchRequestLogState.destroy(); + }); + + it("should emit fetchRequest events", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + await server.start(); + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const fetchRequestEvents: FetchRequestEntryBase[] = []; + client.addEventListener("fetchRequest", (event) => { + fetchRequestEvents.push(event.detail); + }); + + await client.connect(); + await getAllTools(client); + + expect(fetchRequestEvents.length).toBeGreaterThan(0); + }); + + it("should emit fetchRequest events", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + await server.start(); + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const entries: unknown[] = []; + client.addEventListener("fetchRequest", (e) => { + entries.push((e as CustomEvent).detail); + }); + + await client.connect(); + await getAllTools(client); + + expect(entries.length).toBeGreaterThan(0); + }); + }); + + describe("Server Data Management", () => { + it("should auto-fetch server contents when enabled", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + expect((await client.listTools()).tools.length).toBeGreaterThan(0); + expect(client.getCapabilities()).toBeDefined(); + expect(client.getServerInfo()).toBeDefined(); + }); + + it("should not auto-fetch server contents when disabled", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Client no longer stores tools; listTools() still returns server tools when called + expect((await client.listTools()).tools.length).toBeGreaterThan(0); + }); + }); + + describe("Tool Methods", () => { + beforeEach(async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + }); + + it("should list tools", async () => { + const result = await client!.listTools(); + expect(Array.isArray(result.tools)).toBe(true); + expect(result.tools.length).toBeGreaterThan(0); + }); + + it("should call tool with string arguments", async () => { + const tool = await getTool(client!, "echo"); + const result = await client!.callTool(tool, { + message: "hello world", + }); + + expect(result).toHaveProperty("result"); + expect(result.success).toBe(true); + expect(result.result).toHaveProperty("content"); + const content = result.result!.content as ContentBlock[]; + expect(Array.isArray(content)).toBe(true); + expect(content[0]).toHaveProperty("type", "text"); + expect("text" in content[0] && content[0].text).toContain("hello world"); + }); + + it("should call tool with number arguments", async () => { + const tool = await getTool(client!, "get_sum"); + const result = await client!.callTool(tool, { + a: 42, + b: 58, + }); + expect(result.success).toBe(true); + + expect(result.result).toHaveProperty("content"); + const content = result.result!.content as ContentBlock[]; + const resultData = JSON.parse( + "text" in content[0] ? content[0].text : "", + ); + expect(resultData.result).toBe(100); + }); + + it("should call tool with boolean arguments", async () => { + const tool = await getTool(client!, "get_annotated_message"); + const result = await client!.callTool(tool, { + messageType: "success", + includeImage: true, + }); + + expect(result.result).toHaveProperty("content"); + const content = result.result!.content as ContentBlock[]; + expect(content.length).toBeGreaterThan(1); + const hasImage = content.some( + (item: ContentBlock) => "type" in item && item.type === "image", + ); + expect(hasImage).toBe(true); + }); + + it("should return both content and structuredContent for tool with outputSchema (get_temp)", async () => { + const tool = await getTool(client!, "get_temp"); + const result = await client!.callTool(tool, { + city: "Seattle", + units: "C", + }); + + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).toHaveProperty("content"); + expect(result.result).toHaveProperty("structuredContent"); + + const content = result.result!.content as Array<{ + type: string; + text?: string; + }>; + expect(Array.isArray(content)).toBe(true); + expect(content[0].type).toBe("text"); + expect(content[0].text).toContain("Seattle"); + expect(content[0].text).toContain("25"); + expect(content[0].text).toContain("degrees C"); + + const structured = result.result!.structuredContent as Record< + string, + unknown + >; + expect(structured).toEqual({ + temperature: 25, + unit: "C", + city: "Seattle", + }); + }); + + it("should handle tool not found", async () => { + const result = await client!.callTool( + minimalTool("nonexistent-tool"), + {}, + ); + // When tool is not found, the SDK returns an error response, not an exception + expect(result.success).toBe(true); // SDK returns error in result, not as exception + expect(result.result).toHaveProperty("isError", true); + expect(result.result).toBeDefined(); + if (result.result) { + expect(result.result).toHaveProperty("content"); + const content = result.result.content as ContentBlock[]; + expect(content[0]).toHaveProperty("text"); + expect((content[0] as { text: string }).text).toContain("not found"); + } + }); + + it("should paginate tools when maxPageSize is set", async () => { + // Disconnect and create a new server with pagination + await client!.disconnect(); + if (server) { + await server.stop(); + } + + // Create server with 10 tools and page size of 3 + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(10), + maxPageSize: { + tools: 3, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + // First page should have 3 tools + const page1 = await client.listTools(); + expect(page1.tools.length).toBe(3); + expect(page1.nextCursor).toBeDefined(); + expect(page1.tools[0]?.name).toBe("tool_1"); + expect(page1.tools[1]?.name).toBe("tool_2"); + expect(page1.tools[2]?.name).toBe("tool_3"); + + // Second page should have 3 more tools + const page2 = await client.listTools(page1.nextCursor); + expect(page2.tools.length).toBe(3); + expect(page2.nextCursor).toBeDefined(); + expect(page2.tools[0]?.name).toBe("tool_4"); + expect(page2.tools[1]?.name).toBe("tool_5"); + expect(page2.tools[2]?.name).toBe("tool_6"); + + // Third page should have 3 more tools + const page3 = await client.listTools(page2.nextCursor); + expect(page3.tools.length).toBe(3); + expect(page3.nextCursor).toBeDefined(); + expect(page3.tools[0]?.name).toBe("tool_7"); + expect(page3.tools[1]?.name).toBe("tool_8"); + expect(page3.tools[2]?.name).toBe("tool_9"); + + // Fourth page should have 1 tool and no next cursor + const page4 = await client.listTools(page3.nextCursor); + expect(page4.tools.length).toBe(1); + expect(page4.nextCursor).toBeUndefined(); + expect(page4.tools[0]?.name).toBe("tool_10"); + }); + }); + + describe("Resource Methods", () => { + beforeEach(async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + }); + + it("should list resources", async () => { + const resources = await getAllResources(client!); + expect(Array.isArray(resources)).toBe(true); + }); + + it("should read resource", async () => { + const resources = await getAllResources(client!); + if (resources.length > 0) { + const uri = resources[0]!.uri; + const readResult = await client!.readResource(uri); + expect(readResult).toHaveProperty("result"); + expect(readResult.result).toHaveProperty("contents"); + } + }); + + it("should paginate resources when maxPageSize is set", async () => { + // Disconnect and create a new server with pagination + await client!.disconnect(); + if (server) { + await server.stop(); + } + + // Create server with 10 resources and page size of 3 + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(10), + maxPageSize: { + resources: 3, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + // First page should have 3 resources + const page1 = await client.listResources(); + expect(page1.resources.length).toBe(3); + expect(page1.nextCursor).toBeDefined(); + expect(page1.resources[0]?.uri).toBe("test://resource_1"); + expect(page1.resources[1]?.uri).toBe("test://resource_2"); + expect(page1.resources[2]?.uri).toBe("test://resource_3"); + + // Second page should have 3 more resources + const page2 = await client.listResources(page1.nextCursor); + expect(page2.resources.length).toBe(3); + expect(page2.nextCursor).toBeDefined(); + expect(page2.resources[0]?.uri).toBe("test://resource_4"); + expect(page2.resources[1]?.uri).toBe("test://resource_5"); + expect(page2.resources[2]?.uri).toBe("test://resource_6"); + + // Third page should have 3 more resources + const page3 = await client.listResources(page2.nextCursor); + expect(page3.resources.length).toBe(3); + expect(page3.nextCursor).toBeDefined(); + expect(page3.resources[0]?.uri).toBe("test://resource_7"); + expect(page3.resources[1]?.uri).toBe("test://resource_8"); + expect(page3.resources[2]?.uri).toBe("test://resource_9"); + + // Fourth page should have 1 resource and no next cursor + const page4 = await client.listResources(page3.nextCursor); + expect(page4.resources.length).toBe(1); + expect(page4.nextCursor).toBeUndefined(); + expect(page4.resources[0]?.uri).toBe("test://resource_10"); + + const allResources = await getAllResources(client); + expect(allResources.length).toBe(10); + }); + + it("should suppress events during listAllResources pagination and emit final event", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { + resources: 2, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + const managedState = new ManagedResourcesState(client); + const events: Resource[][] = []; + managedState.addEventListener("resourcesChange", (e) => { + events.push(e.detail); + }); + + await managedState.refresh(); + expect(managedState.getResources().length).toBe(6); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(6); + managedState.destroy(); + }); + + it("should accumulate resources when paginating with cursor", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourcesState(client); + + expect(pagedState.getResources().length).toBe(0); + + const page1 = await pagedState.loadPage(); + expect(page1.resources.length).toBe(2); + expect(pagedState.getResources().length).toBe(2); + expect(pagedState.getResources()[0]?.uri).toBe("test://resource_1"); + expect(pagedState.getResources()[1]?.uri).toBe("test://resource_2"); + + const page2 = await pagedState.loadPage(page1.nextCursor); + expect(page2.resources.length).toBe(2); + expect(pagedState.getResources().length).toBe(4); + expect(pagedState.getResources()[2]?.uri).toBe("test://resource_3"); + expect(pagedState.getResources()[3]?.uri).toBe("test://resource_4"); + + const page3 = await pagedState.loadPage(page2.nextCursor); + expect(page3.resources.length).toBe(2); + expect(pagedState.getResources().length).toBe(6); + expect(pagedState.getResources()[4]?.uri).toBe("test://resource_5"); + expect(pagedState.getResources()[5]?.uri).toBe("test://resource_6"); + + const page1Again = await pagedState.loadPage(); + expect(page1Again.resources.length).toBe(2); + expect(pagedState.getResources().length).toBe(2); + expect(pagedState.getResources()[0]?.uri).toBe("test://resource_1"); + + pagedState.destroy(); + }); + + it("should emit resourcesChange events when paginating", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourcesState(client); + const events: Resource[][] = []; + pagedState.addEventListener("resourcesChange", (e) => { + events.push(e.detail); + }); + + const page1 = await pagedState.loadPage(); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(2); + + await pagedState.loadPage(page1.nextCursor); + expect(events.length).toBe(2); + expect(events[1]!.length).toBe(4); + + pagedState.destroy(); + }); + + it("should emit resourcesChange when loading pages via PagedResourcesState", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourcesState(client); + const events: Resource[][] = []; + pagedState.addEventListener("resourcesChange", (e) => { + events.push(e.detail); + }); + + await pagedState.loadPage(); + expect(pagedState.getResources().length).toBe(2); + expect(events.length).toBe(1); + + pagedState.destroy(); + }); + + it("should clear resources and emit event", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(3), + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourcesState(client); + await pagedState.loadPage(); + expect(pagedState.getResources().length).toBe(3); + + const events: Resource[][] = []; + pagedState.addEventListener("resourcesChange", (e) => { + events.push(e.detail); + }); + + pagedState.clear(); + expect(pagedState.getResources().length).toBe(0); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(0); + + pagedState.destroy(); + }); + }); + + describe("Resource Template Methods", () => { + beforeEach(async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + }); + + it("should list resource templates", async () => { + const resourceTemplates = await getAllResourceTemplates(client!); + expect(Array.isArray(resourceTemplates)).toBe(true); + expect(resourceTemplates.length).toBeGreaterThan(0); + + const templates = resourceTemplates; + const fileTemplate = templates.find((t) => t.name === "file"); + expect(fileTemplate).toBeDefined(); + expect(fileTemplate?.uriTemplate).toBe("file:///{path}"); + }); + + it("should read resource from template", async () => { + const templates = await getAllResourceTemplates(client!); + const fileTemplate = templates.find((t) => t.name === "file"); + expect(fileTemplate).toBeDefined(); + + // Use a URI that matches the template pattern file:///{path} + // The path variable will be "test.txt" + const expandedUri = "file:///test.txt"; + + // Read the resource using the expanded URI + const readResult = await client!.readResource(expandedUri); + expect(readResult).toHaveProperty("result"); + expect(readResult.result).toHaveProperty("contents"); + const contents = readResult.result.contents; + expect(Array.isArray(contents)).toBe(true); + expect(contents.length).toBeGreaterThan(0); + + const content = contents[0]; + expect(content).toHaveProperty("uri"); + if (content && "text" in content) { + expect(content.text).toContain("Mock file content for: test.txt"); + } + }); + + it("should include resources from template list callback in listResources", async () => { + // Create a server with a resource template that has a list callback + const listCallback = async () => { + return ["file:///file1.txt", "file:///file2.txt", "file:///file3.txt"]; + }; + + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [ + createFileResourceTemplate(undefined, listCallback), + ], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + const resources = await getAllResources(client); + expect(Array.isArray(resources)).toBe(true); + + // Verify that the resources from the list callback are included + const uris = resources.map((r) => r.uri); + expect(uris).toContain("file:///file1.txt"); + expect(uris).toContain("file:///file2.txt"); + expect(uris).toContain("file:///file3.txt"); + }); + + it("should paginate resource templates when maxPageSize is set", async () => { + // Disconnect and create a new server with pagination + await client!.disconnect(); + if (server) { + await server.stop(); + } + + // Create server with 10 resource templates and page size of 3 + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(10), + maxPageSize: { + resourceTemplates: 3, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + // First page should have 3 templates + const page1 = await client.listResourceTemplates(); + expect(page1.resourceTemplates.length).toBe(3); + expect(page1.nextCursor).toBeDefined(); + expect(page1.resourceTemplates[0]?.uriTemplate).toBe( + "test://template_1/{param}", + ); + expect(page1.resourceTemplates[1]?.uriTemplate).toBe( + "test://template_2/{param}", + ); + expect(page1.resourceTemplates[2]?.uriTemplate).toBe( + "test://template_3/{param}", + ); + + // Second page should have 3 more templates + const page2 = await client.listResourceTemplates(page1.nextCursor); + expect(page2.resourceTemplates.length).toBe(3); + expect(page2.nextCursor).toBeDefined(); + expect(page2.resourceTemplates[0]?.uriTemplate).toBe( + "test://template_4/{param}", + ); + expect(page2.resourceTemplates[1]?.uriTemplate).toBe( + "test://template_5/{param}", + ); + expect(page2.resourceTemplates[2]?.uriTemplate).toBe( + "test://template_6/{param}", + ); + + // Third page should have 3 more templates + const page3 = await client.listResourceTemplates(page2.nextCursor); + expect(page3.resourceTemplates.length).toBe(3); + expect(page3.nextCursor).toBeDefined(); + expect(page3.resourceTemplates[0]?.uriTemplate).toBe( + "test://template_7/{param}", + ); + expect(page3.resourceTemplates[1]?.uriTemplate).toBe( + "test://template_8/{param}", + ); + expect(page3.resourceTemplates[2]?.uriTemplate).toBe( + "test://template_9/{param}", + ); + + // Fourth page should have 1 template and no next cursor + const page4 = await client.listResourceTemplates(page3.nextCursor); + expect(page4.resourceTemplates.length).toBe(1); + expect(page4.nextCursor).toBeUndefined(); + expect(page4.resourceTemplates[0]?.uriTemplate).toBe( + "test://template_10/{param}", + ); + + const allTemplates = await getAllResourceTemplates(client); + expect(allTemplates.length).toBe(10); + }); + + it("should accumulate resource templates when paginating with cursor", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(6), + maxPageSize: { resourceTemplates: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourceTemplatesState(client); + + expect(pagedState.getResourceTemplates().length).toBe(0); + + const page1 = await pagedState.loadPage(); + expect(page1.resourceTemplates.length).toBe(2); + expect(pagedState.getResourceTemplates().length).toBe(2); + expect(pagedState.getResourceTemplates()[0]?.uriTemplate).toBe( + "test://template_1/{param}", + ); + expect(pagedState.getResourceTemplates()[1]?.uriTemplate).toBe( + "test://template_2/{param}", + ); + + const page2 = await pagedState.loadPage(page1.nextCursor); + expect(page2.resourceTemplates.length).toBe(2); + expect(pagedState.getResourceTemplates().length).toBe(4); + expect(pagedState.getResourceTemplates()[2]?.uriTemplate).toBe( + "test://template_3/{param}", + ); + expect(pagedState.getResourceTemplates()[3]?.uriTemplate).toBe( + "test://template_4/{param}", + ); + + const page3 = await pagedState.loadPage(page2.nextCursor); + expect(page3.resourceTemplates.length).toBe(2); + expect(pagedState.getResourceTemplates().length).toBe(6); + expect(pagedState.getResourceTemplates()[4]?.uriTemplate).toBe( + "test://template_5/{param}", + ); + expect(pagedState.getResourceTemplates()[5]?.uriTemplate).toBe( + "test://template_6/{param}", + ); + + const page1Again = await pagedState.loadPage(); + expect(page1Again.resourceTemplates.length).toBe(2); + expect(pagedState.getResourceTemplates().length).toBe(2); + expect(pagedState.getResourceTemplates()[0]?.uriTemplate).toBe( + "test://template_1/{param}", + ); + + pagedState.destroy(); + }); + + it("should clear resource templates and emit event", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(3), + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedResourceTemplatesState(client); + await pagedState.loadPage(); + expect(pagedState.getResourceTemplates().length).toBe(3); + + const events: ResourceTemplate[][] = []; + pagedState.addEventListener("resourceTemplatesChange", (e) => { + events.push(e.detail); + }); + + pagedState.clear(); + expect(pagedState.getResourceTemplates().length).toBe(0); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(0); + + pagedState.destroy(); + }); + }); + + describe("Prompt Methods", () => { + beforeEach(async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + }); + + it("should list prompts", async () => { + const prompts = await getAllPrompts(client!); + expect(Array.isArray(prompts)).toBe(true); + }); + + it("should paginate prompts when maxPageSize is set", async () => { + // Disconnect and create a new server with pagination + await client!.disconnect(); + if (server) { + await server.stop(); + } + + // Create server with 10 prompts and page size of 3 + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(10), + maxPageSize: { + prompts: 3, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + // First page should have 3 prompts + const page1 = await client.listPrompts(); + expect(page1.prompts.length).toBe(3); + expect(page1.nextCursor).toBeDefined(); + expect(page1.prompts[0]?.name).toBe("prompt_1"); + expect(page1.prompts[1]?.name).toBe("prompt_2"); + expect(page1.prompts[2]?.name).toBe("prompt_3"); + + // Second page should have 3 more prompts + const page2 = await client.listPrompts(page1.nextCursor); + expect(page2.prompts.length).toBe(3); + expect(page2.nextCursor).toBeDefined(); + expect(page2.prompts[0]?.name).toBe("prompt_4"); + expect(page2.prompts[1]?.name).toBe("prompt_5"); + expect(page2.prompts[2]?.name).toBe("prompt_6"); + + // Third page should have 3 more prompts + const page3 = await client.listPrompts(page2.nextCursor); + expect(page3.prompts.length).toBe(3); + expect(page3.nextCursor).toBeDefined(); + expect(page3.prompts[0]?.name).toBe("prompt_7"); + expect(page3.prompts[1]?.name).toBe("prompt_8"); + expect(page3.prompts[2]?.name).toBe("prompt_9"); + + // Fourth page should have 1 prompt and no next cursor + const page4 = await client.listPrompts(page3.nextCursor); + expect(page4.prompts.length).toBe(1); + expect(page4.nextCursor).toBeUndefined(); + expect(page4.prompts[0]?.name).toBe("prompt_10"); + + const allPrompts = await getAllPrompts(client); + expect(allPrompts.length).toBe(10); + }); + + it("should suppress events during listAllPrompts pagination and emit final event", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { prompts: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + + const managedState = new ManagedPromptsState(client); + const events: Prompt[][] = []; + managedState.addEventListener("promptsChange", (e) => { + events.push(e.detail); + }); + + await managedState.refresh(); + expect(managedState.getPrompts().length).toBe(6); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(6); + managedState.destroy(); + }); + + it("should accumulate prompts when paginating with cursor", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { prompts: 2 }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedPromptsState(client); + + expect(pagedState.getPrompts().length).toBe(0); + + const page1 = await pagedState.loadPage(); + expect(page1.prompts.length).toBe(2); + expect(pagedState.getPrompts().length).toBe(2); + expect(pagedState.getPrompts()[0]?.name).toBe("prompt_1"); + expect(pagedState.getPrompts()[1]?.name).toBe("prompt_2"); + + const page2 = await pagedState.loadPage(page1.nextCursor); + expect(page2.prompts.length).toBe(2); + expect(pagedState.getPrompts().length).toBe(4); + expect(pagedState.getPrompts()[2]?.name).toBe("prompt_3"); + expect(pagedState.getPrompts()[3]?.name).toBe("prompt_4"); + + const page3 = await pagedState.loadPage(page2.nextCursor); + expect(page3.prompts.length).toBe(2); + expect(pagedState.getPrompts().length).toBe(6); + expect(pagedState.getPrompts()[4]?.name).toBe("prompt_5"); + expect(pagedState.getPrompts()[5]?.name).toBe("prompt_6"); + + const page1Again = await pagedState.loadPage(); + expect(page1Again.prompts.length).toBe(2); + expect(pagedState.getPrompts().length).toBe(2); + expect(pagedState.getPrompts()[0]?.name).toBe("prompt_1"); + + pagedState.destroy(); + }); + + it("should emit promptsChange events when paginating", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { + prompts: 2, + }, + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedPromptsState(client); + const events: Prompt[][] = []; + pagedState.addEventListener("promptsChange", (e) => { + events.push(e.detail); + }); + + const page1 = await pagedState.loadPage(); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(2); + + await pagedState.loadPage(page1.nextCursor); + expect(events.length).toBe(2); + expect(events[1]!.length).toBe(4); + + pagedState.destroy(); + }); + + it("should clear prompts and emit event", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(3), + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + + await client.connect(); + const pagedState = new PagedPromptsState(client); + await pagedState.loadPage(); + expect(pagedState.getPrompts().length).toBe(3); + + const events: Prompt[][] = []; + pagedState.addEventListener("promptsChange", (e) => { + events.push(e.detail); + }); + + pagedState.clear(); + expect(pagedState.getPrompts().length).toBe(0); + expect(events.length).toBe(1); + expect(events[0]!.length).toBe(0); + + pagedState.destroy(); + }); + }); + + describe("Progress Tracking", () => { + it("should dispatch progressNotification events when progress notifications are received", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: true, + }, + ); + + await client.connect(); + + const progressToken = 12345; + + const sendProgressTool = await getTool(client, "send_progress"); + client.callTool( + sendProgressTool, + { + units: 3, + delayMs: 50, + total: 3, + message: "Test progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ); + + const progressEvents = await waitForProgressCount(client, 3, { + timeout: 3000, + }); + + expect(progressEvents.length).toBe(3); + expect(progressEvents[0]).toMatchObject({ + progress: 1, + total: 3, + message: "Test progress (1/3)", + progressToken: progressToken.toString(), + }); + + // Verify second progress event + expect(progressEvents[1]).toMatchObject({ + progress: 2, + total: 3, + message: "Test progress (2/3)", + progressToken: progressToken.toString(), + }); + + // Verify third progress event + expect(progressEvents[2]).toMatchObject({ + progress: 3, + total: 3, + message: "Test progress (3/3)", + progressToken: progressToken.toString(), + }); + + await client!.disconnect(); + await server.stop(); + }); + + it("should not dispatch progressNotification events when progress is disabled", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: false, // Disable progress + }, + ); + + await client.connect(); + + const progressEvents: Progress[] = []; + const progressListener = (event: TypedEvent<"progressNotification">) => { + progressEvents.push(event.detail); + }; + client.addEventListener("progressNotification", progressListener); + + const progressToken = 12345; + + // Call the tool with progressToken in metadata + const sendProgressTool = await getTool(client, "send_progress"); + await client.callTool( + sendProgressTool, + { + units: 2, + delayMs: 50, + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ); + + // Observation window: we assert no progressNotification events; can't wait for a non-event. + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Remove listener + client.removeEventListener("progressNotification", progressListener); + + // Verify no progress events were received + expect(progressEvents.length).toBe(0); + + await client!.disconnect(); + await server.stop(); + }); + + it("should handle progress notifications without total", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: true, + }, + ); + + await client.connect(); + + const progressToken = 67890; + + const sendProgressTool2 = await getTool(client, "send_progress"); + client.callTool( + sendProgressTool2, + { + units: 2, + delayMs: 50, + message: "Indeterminate progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ); + + const progressEvents = await waitForProgressCount(client, 2, { + timeout: 3000, + }); + + expect(progressEvents.length).toBe(2); + expect(progressEvents[0]).toMatchObject({ + progress: 1, + message: "Indeterminate progress (1/2)", + progressToken: progressToken.toString(), + }); + expect((progressEvents[0] as { total?: number }).total).toBeUndefined(); + + expect(progressEvents[1]).toMatchObject({ + progress: 2, + message: "Indeterminate progress (2/2)", + progressToken: progressToken.toString(), + }); + expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); + + await client!.disconnect(); + await server.stop(); + }); + + it("should complete when timeout and resetTimeoutOnProgress are set (options passed through)", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: true, + timeout: 2000, + resetTimeoutOnProgress: true, + }, + ); + + await client.connect(); + + const progressToken = 999; + const sendProgressTool = await getTool(client, "send_progress"); + const result = await client.callTool( + sendProgressTool, + { units: 3, delayMs: 100, total: 3, message: "Timeout test" }, + undefined, + { progressToken: progressToken.toString() }, + ); + + expect(result.success).toBe(true); + expect((result.result as { content?: unknown[] }).content).toBeDefined(); + const text = ( + result.result as { content?: { type: string; text?: string }[] } + ).content?.find((c) => c.type === "text")?.text; + expect(text).toContain("Completed 3 progress notifications"); + + await client.disconnect(); + await server.stop(); + }); + + it("should not timeout when resetTimeoutOnProgress is true and progress is sent (reset extends timeout)", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: true, + timeout: 350, + resetTimeoutOnProgress: true, + }, + ); + + await client.connect(); + + const sendProgressTool = await getTool(client, "send_progress"); + const result = await client.callTool( + sendProgressTool, + { units: 4, delayMs: 200, total: 4, message: "Reset test" }, + undefined, + { progressToken: "reset-test" }, + ); + + expect(result.success).toBe(true); + expect((result.result as { content?: unknown[] }).content).toBeDefined(); + const text = ( + result.result as { content?: { type: string; text?: string }[] } + ).content?.find((c) => c.type === "text")?.text; + expect(text).toContain("Completed 4 progress notifications"); + + await client.disconnect(); + await server.stop(); + }); + + it("should timeout with RequestTimeout when resetTimeoutOnProgress is false and gap exceeds timeout", async () => { + const { createSendProgressTool } = + await import("@modelcontextprotocol/inspector-test-server"); + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendProgressTool()], + }); + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + progress: true, + timeout: 150, + resetTimeoutOnProgress: false, + }, + ); + + await client.connect(); + + const progressToken = 888; + const sendProgressToolTimeout = await getTool(client, "send_progress"); + let err: unknown; + try { + await client.callTool( + sendProgressToolTimeout, + { units: 4, delayMs: 200, total: 4, message: "Timeout test" }, + undefined, + { progressToken: progressToken.toString() }, + ); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(McpError); + expect((err as McpError).code).toBe(ErrorCode.RequestTimeout); + + await client.disconnect(); + await server.stop(); + }); + }); + + describe("Logging", () => { + it("should set logging level when server supports it", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + initialLoggingLevel: "debug", + }, + ); + + await client.connect(); + + // If server supports logging, the level should be set + // We can't directly verify this, but it shouldn't throw + const capabilities = client.getCapabilities(); + if (capabilities?.logging) { + await client.setLoggingLevel("info"); + } + }); + + it("should track stderr logs for stdio transport", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + pipeStderr: true, + }, + ); + + const stderrLogState = new StderrLogState(client); + await client.connect(); + + const testMessage = `stderr-direct-${Date.now()}`; + const writeToStderrTool = await getTool(client, "write_to_stderr"); + await client.callTool(writeToStderrTool, { message: testMessage }); + + const logs = stderrLogState.getStderrLogs(); + expect(Array.isArray(logs)).toBe(true); + const matching = logs.filter((l) => l.message.includes(testMessage)); + expect(matching.length).toBeGreaterThan(0); + expect(matching[0]!.message).toContain(testMessage); + stderrLogState.destroy(); + }); + }); + + describe("Events", () => { + it("should emit statusChange events", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + const statuses: ConnectionStatus[] = []; + client.addEventListener("statusChange", (event) => { + statuses.push(event.detail); + }); + + await client.connect(); + await client.disconnect(); + + expect(statuses).toContain("connecting"); + expect(statuses).toContain("connected"); + expect(statuses).toContain("disconnected"); + }); + + it("should emit connect event", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + let connectFired = false; + client.addEventListener("connect", () => { + connectFired = true; + }); + + await client.connect(); + expect(connectFired).toBe(true); + }); + + it("should emit disconnect event", async () => { + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + let disconnectFired = false; + client.addEventListener("disconnect", () => { + disconnectFired = true; + }); + + await client.connect(); + await client.disconnect(); + expect(disconnectFired).toBe(true); + }); + }); + + describe("Sampling Requests", () => { + it("should handle sampling requests from server and respond", async () => { + // Create a test server with the collect_sample tool + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createCollectSampleTool()], + serverType: "streamable-http", + }); + + await server.start(); + + // Create client with sampling enabled + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + sample: true, // Enable sampling capability + }, + ); + + await client.connect(); + + // Set up Promise to wait for sampling request event + const samplingRequestPromise = new Promise( + (resolve) => { + client!.addEventListener( + "newPendingSample", + (event) => { + resolve(event.detail); + }, + { once: true }, + ); + }, + ); + + // Start the tool call (don't await yet - it will block until sampling is responded to) + const collectSampleTool = await getTool(client, "collect_sample"); + const toolResultPromise = client.callTool(collectSampleTool, { + text: "Hello, world!", + }); + + // Wait for the sampling request to arrive via event + const pendingSample = await samplingRequestPromise; + + // Verify we received a sampling request + expect(pendingSample.request.method).toBe("sampling/createMessage"); + const messages = pendingSample.request.params.messages; + expect(messages.length).toBeGreaterThan(0); + const firstMessage = messages[0]; + expect(firstMessage).toBeDefined(); + if ( + firstMessage && + firstMessage.content && + typeof firstMessage.content === "object" && + "text" in firstMessage.content + ) { + expect((firstMessage.content as { text: string }).text).toBe( + "Hello, world!", + ); + } + + // Respond to the sampling request + const samplingResponse: CreateMessageResult = { + model: "test-model", + role: "assistant", + stopReason: "endTurn", + content: { + type: "text", + text: "This is a test response", + }, + }; + + await pendingSample.respond(samplingResponse); + + // Now await the tool result (it should complete now that we've responded) + const toolResult = await toolResultPromise; + + // Verify the tool result contains the sampling response + expect(toolResult).toBeDefined(); + expect(toolResult.success).toBe(true); + expect(toolResult.result).toBeDefined(); + expect(toolResult.result!.content).toBeDefined(); + expect(Array.isArray(toolResult.result!.content)).toBe(true); + const toolContent = toolResult.result!.content as ContentBlock[]; + expect(toolContent.length).toBeGreaterThan(0); + const toolMessage = toolContent[0]; + expect(toolMessage).toBeDefined(); + expect(toolMessage.type).toBe("text"); + if (toolMessage.type === "text") { + expect(toolMessage.text).toContain("Sampling response:"); + expect(toolMessage.text).toContain("test-model"); + expect(toolMessage.text).toContain("This is a test response"); + } + + // Verify the pending sample was removed + const pendingSamples = client.getPendingSamples(); + expect(pendingSamples.length).toBe(0); + }); + }); + + describe("Server-Initiated Notifications", () => { + it("should receive server-initiated notifications via stdio transport", async () => { + // Note: stdio test server uses getDefaultServerConfig which now includes send_notification tool + // Create client with stdio transport + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Set up Promise to wait for notification + const notificationPromise = new Promise((resolve) => { + client!.addEventListener("message", (event) => { + const entry = event.detail; + if (entry.direction === "notification") { + resolve(entry); + } + }); + }); + + // Call the send_notification tool + const sendNotifTool = await getTool(client, "send_notification"); + await client.callTool(sendNotifTool, { + message: "Test notification from stdio", + level: "info", + }); + + // Wait for the notification + const notificationEntry = await notificationPromise; + + // Validate the notification + expect(notificationEntry).toBeDefined(); + expect(notificationEntry.direction).toBe("notification"); + if ("method" in notificationEntry.message) { + expect(notificationEntry.message.method).toBe("notifications/message"); + if ("params" in notificationEntry.message) { + const params = notificationEntry.message.params as Record< + string, + unknown + >; + expect((params.data as { message: string }).message).toBe( + "Test notification from stdio", + ); + expect(params.level).toBe("info"); + expect(params.logger).toBe("test-server"); + } + } + }); + + it("should receive server-initiated notifications via SSE transport", async () => { + // Create a test server with the send_notification tool and logging enabled + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendNotificationTool()], + serverType: "sse", + logging: true, // Required for notifications/message + }); + + await server.start(); + + // Create client with SSE transport + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Set up Promise to wait for notification + const notificationPromise = new Promise((resolve) => { + client!.addEventListener("message", (event) => { + const entry = event.detail; + if (entry.direction === "notification") { + resolve(entry); + } + }); + }); + + // Call the send_notification tool + const sendNotifToolSse = await getTool(client, "send_notification"); + await client.callTool(sendNotifToolSse, { + message: "Test notification from SSE", + level: "warning", + }); + + // Wait for the notification + const notificationEntry = await notificationPromise; + + // Validate the notification + expect(notificationEntry).toBeDefined(); + expect(notificationEntry.direction).toBe("notification"); + if ("method" in notificationEntry.message) { + expect(notificationEntry.message.method).toBe("notifications/message"); + if ("params" in notificationEntry.message) { + const params = notificationEntry.message.params as Record< + string, + unknown + >; + expect((params.data as { message: string }).message).toBe( + "Test notification from SSE", + ); + expect(params.level).toBe("warning"); + expect(params.logger).toBe("test-server"); + } + } + }); + + it("should receive server-initiated notifications via streamable-http transport", async () => { + // Create a test server with the send_notification tool and logging enabled + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createSendNotificationTool()], + serverType: "streamable-http", + logging: true, // Required for notifications/message + }); + + await server.start(); + + // Create client with streamable-http transport + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Set up Promise to wait for notification + const notificationPromise = new Promise((resolve) => { + client!.addEventListener("message", (event) => { + const entry = event.detail; + if (entry.direction === "notification") { + resolve(entry); + } + }); + }); + + // Call the send_notification tool + const sendNotifToolHttp = await getTool(client, "send_notification"); + await client.callTool(sendNotifToolHttp, { + message: "Test notification from streamable-http", + level: "error", + }); + + // Wait for the notification + const notificationEntry = await notificationPromise; + + // Validate the notification + expect(notificationEntry).toBeDefined(); + expect(notificationEntry.direction).toBe("notification"); + if ("method" in notificationEntry.message) { + expect(notificationEntry.message.method).toBe("notifications/message"); + if ("params" in notificationEntry.message) { + const params = notificationEntry.message.params as Record< + string, + unknown + >; + expect((params.data as { message: string }).message).toBe( + "Test notification from streamable-http", + ); + expect(params.level).toBe("error"); + expect(params.logger).toBe("test-server"); + } + } + }); + }); + + describe("Elicitation Requests", () => { + it("should handle form-based elicitation requests from server and respond", async () => { + // Create a test server with the collectElicitation tool + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createCollectFormElicitationTool()], + serverType: "streamable-http", + }); + + await server.start(); + + // Create client with elicitation enabled + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + elicit: true, // Enable elicitation capability + }, + ); + + await client.connect(); + + // Set up Promise to wait for elicitation request event + const elicitationRequestPromise = new Promise( + (resolve) => { + client!.addEventListener( + "newPendingElicitation", + (event) => { + resolve(event.detail); + }, + { once: true }, + ); + }, + ); + + // Start the tool call (don't await yet - it will block until elicitation is responded to) + const collectElicitationTool = await getTool( + client, + "collect_elicitation", + ); + const toolResultPromise = client.callTool(collectElicitationTool, { + message: "Please provide your name", + schema: { + type: "object", + properties: { + name: { + type: "string", + description: "Your name", + }, + }, + required: ["name"], + }, + }); + + // Wait for the elicitation request to arrive via event + const pendingElicitation = await elicitationRequestPromise; + + // Verify we received an elicitation request + expect(pendingElicitation.request.method).toBe("elicitation/create"); + expect(pendingElicitation.request.params.message).toBe( + "Please provide your name", + ); + if ("requestedSchema" in pendingElicitation.request.params) { + expect(pendingElicitation.request.params.requestedSchema).toBeDefined(); + expect(pendingElicitation.request.params.requestedSchema.type).toBe( + "object", + ); + } + + // Respond to the elicitation request + const elicitationResponse: ElicitResult = { + action: "accept", + content: { + name: "Test User", + }, + }; + + await pendingElicitation.respond(elicitationResponse); + + // Now await the tool result (it should complete now that we've responded) + const toolResult = await toolResultPromise; + + // Verify the tool result contains the elicitation response + expect(toolResult).toBeDefined(); + expect(toolResult.success).toBe(true); + expect(toolResult.result).toBeDefined(); + expect(toolResult.result!.content).toBeDefined(); + expect(Array.isArray(toolResult.result!.content)).toBe(true); + const toolContent = toolResult.result!.content as ContentBlock[]; + expect(toolContent.length).toBeGreaterThan(0); + const toolMessage = toolContent[0]; + expect(toolMessage).toBeDefined(); + expect(toolMessage.type).toBe("text"); + if (toolMessage.type === "text") { + expect(toolMessage.text).toContain("Elicitation response:"); + expect(toolMessage.text).toContain("accept"); + expect(toolMessage.text).toContain("Test User"); + } + + // Verify the pending elicitation was removed + const pendingElicitations = client.getPendingElicitations(); + expect(pendingElicitations.length).toBe(0); + }); + + it("should handle URL-based elicitation requests from server and respond", async () => { + // Create a test server with the collect_url_elicitation tool + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createCollectUrlElicitationTool()], + serverType: "streamable-http", + }); + + await server.start(); + + // Create client with elicitation enabled + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + elicit: { url: true }, // Enable elicitation capability + }, + ); + + await client.connect(); + + // Set up Promise to wait for elicitation request event + const elicitationRequestPromise = new Promise( + (resolve) => { + client!.addEventListener( + "newPendingElicitation", + (event) => { + resolve(event.detail); + }, + { once: true }, + ); + }, + ); + + // Start the tool call (don't await yet - it will block until elicitation is responded to) + const collectUrlElicitationTool = await getTool( + client, + "collect_url_elicitation", + ); + const toolResultPromise = client.callTool(collectUrlElicitationTool, { + message: "Please visit the URL to complete authentication", + url: "https://example.com/auth", + elicitationId: "test-url-elicitation-123", + }); + + // Wait for the elicitation request to arrive via event + const pendingElicitation = await elicitationRequestPromise; + + // Verify we received a URL-based elicitation request + expect(pendingElicitation.request.method).toBe("elicitation/create"); + expect(pendingElicitation.request.params.message).toBe( + "Please visit the URL to complete authentication", + ); + expect(pendingElicitation.request.params.mode).toBe("url"); + if (pendingElicitation.request.params.mode === "url") { + expect(pendingElicitation.request.params.url).toBe( + "https://example.com/auth", + ); + expect(pendingElicitation.request.params.elicitationId).toBe( + "test-url-elicitation-123", + ); + } + + // Respond to the URL-based elicitation request + const elicitationResponse: ElicitResult = { + action: "accept", + content: { + // URL-based elicitation typically doesn't have form data, but we can include metadata + completed: true, + }, + }; + + await pendingElicitation.respond(elicitationResponse); + + // Now await the tool result (it should complete now that we've responded) + const toolResult = await toolResultPromise; + + // Verify the tool result contains the elicitation response + expect(toolResult).toBeDefined(); + expect(toolResult.success).toBe(true); + expect(toolResult.result).toBeDefined(); + expect(toolResult.result!.content).toBeDefined(); + expect(Array.isArray(toolResult.result!.content)).toBe(true); + const toolContent = toolResult.result!.content as ContentBlock[]; + expect(toolContent.length).toBeGreaterThan(0); + const toolMessage = toolContent[0]; + expect(toolMessage).toBeDefined(); + expect(toolMessage.type).toBe("text"); + if (toolMessage.type === "text") { + expect(toolMessage.text).toContain("URL elicitation response:"); + expect(toolMessage.text).toContain("accept"); + } + + // Verify the pending elicitation was removed + const pendingElicitations = client.getPendingElicitations(); + expect(pendingElicitations.length).toBe(0); + }); + + it("should handle url_elicitation_form: accept elicitation, receive completion notification, update pending state, and return tool result", async () => { + const submittedValue = "inspector-client-test-value-99"; + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createUrlElicitationFormTool()], + serverType: "streamable-http", + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + elicit: { url: true }, + }, + ); + + await client.connect(); + + // Track pendingElicitationsChange events: expect [1] when elicitation arrives, [0] when complete notification received + const pendingElicitationsChangeEvents: ElicitationCreateMessage[][] = []; + client!.addEventListener( + "pendingElicitationsChange", + (event: TypedEvent<"pendingElicitationsChange">) => { + pendingElicitationsChangeEvents.push([...event.detail]); + }, + ); + + const elicitationRequestPromise = new Promise( + (resolve) => { + client!.addEventListener( + "newPendingElicitation", + (event) => resolve(event.detail), + { once: true }, + ); + }, + ); + + const urlElicitationFormTool = await getTool( + client, + "url_elicitation_form", + ); + const toolResultPromise = client.callTool(urlElicitationFormTool, {}); + + const pendingElicitation = await elicitationRequestPromise; + + expect(pendingElicitation.request.method).toBe("elicitation/create"); + expect(pendingElicitation.request.params?.mode).toBe("url"); + const url = + pendingElicitation.request.params?.mode === "url" + ? pendingElicitation.request.params.url + : null; + const elicitationId = + pendingElicitation.request.params?.mode === "url" + ? pendingElicitation.request.params.elicitationId + : null; + expect(url).toBeTruthy(); + expect(elicitationId).toBeTruthy(); + + expect(client.getPendingElicitations()).toHaveLength(1); + + // Respond with accept (unblocks server); then submit form to trigger completion notification + await pendingElicitation.respond({ action: "accept" }); + + const formData = new URLSearchParams({ + value: submittedValue, + elicitation: elicitationId!, + }); + await fetch(url!, { + method: "POST", + body: formData, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + + const toolResult = await toolResultPromise; + + expect(toolResult).toBeDefined(); + expect(toolResult.success).toBe(true); + expect(toolResult.result?.content).toBeDefined(); + const content = toolResult.result!.content as Array<{ + type: string; + text?: string; + }>; + const textBlock = content.find((c) => c.type === "text"); + expect(textBlock?.text).toContain("Collected value:"); + expect(textBlock?.text).toContain(submittedValue); + + expect(client.getPendingElicitations()).toHaveLength(0); + + // Verify event sequence: addPendingElicitation -> [1], then complete notification -> [0] + expect(pendingElicitationsChangeEvents.length).toBeGreaterThanOrEqual(2); + expect(pendingElicitationsChangeEvents[0]).toHaveLength(1); + const lastEvent = + pendingElicitationsChangeEvents[ + pendingElicitationsChangeEvents.length - 1 + ]; + expect(lastEvent).toHaveLength(0); + }); + }); + + describe("Roots Support", () => { + it("should handle roots/list request from server and return roots", async () => { + // Create a test server with the list_roots tool + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createListRootsTool()], + serverType: "streamable-http", + }); + + await server.start(); + + // Create client with roots enabled + const initialRoots = [ + { uri: "file:///test1", name: "Test Root 1" }, + { uri: "file:///test2", name: "Test Root 2" }, + ]; + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + roots: initialRoots, // Enable roots capability + }, + ); + + await client.connect(); + + // Call the list_roots tool - it will call roots/list on the client + const listRootsTool = await getTool(client, "list_roots"); + const toolResult = await client.callTool(listRootsTool, {}); + + // Verify the tool result contains the roots + expect(toolResult).toBeDefined(); + expect(toolResult.success).toBe(true); + expect(toolResult.result).toBeDefined(); + expect(toolResult.result!.content).toBeDefined(); + expect(Array.isArray(toolResult.result!.content)).toBe(true); + const toolContent = toolResult.result!.content as ContentBlock[]; + expect(toolContent.length).toBeGreaterThan(0); + const toolMessage = toolContent[0]; + expect(toolMessage).toBeDefined(); + expect(toolMessage.type).toBe("text"); + if (toolMessage.type === "text") { + expect(toolMessage.text).toContain("Roots:"); + expect(toolMessage.text).toContain("file:///test1"); + expect(toolMessage.text).toContain("file:///test2"); + } + + // Verify getRoots() returns the roots + const roots = client.getRoots(); + expect(roots).toEqual(initialRoots); + + await client.disconnect(); + await server.stop(); + }); + + it("should send roots/list_changed notification when roots are updated", async () => { + // Create a test server - clients can send roots/list_changed notifications to any server + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + serverType: "streamable-http", + }); + + await server.start(); + + // Create client with roots enabled + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + roots: [], // Enable roots capability with empty array + }, + ); + + await client.connect(); + + // Clear any recorded requests from connection + server.clearRecordings(); + + // Update roots + const newRoots = [ + { uri: "file:///new1", name: "New Root 1" }, + { uri: "file:///new2", name: "New Root 2" }, + ]; + await client.setRoots(newRoots); + + const rootsChangedNotification = await server.waitUntilRecorded( + (req) => req.method === "notifications/roots/list_changed", + { timeout: 5000, interval: 10 }, + ); + + expect(rootsChangedNotification.method).toBe( + "notifications/roots/list_changed", + ); + + // Verify getRoots() returns the new roots + const roots = client.getRoots(); + expect(roots).toEqual(newRoots); + + // Verify rootsChange event was dispatched + const rootsChangePromise = new Promise((resolve) => { + client!.addEventListener( + "rootsChange", + (event) => { + resolve(event); + }, + { once: true }, + ); + }); + + await client.setRoots([{ uri: "file:///updated", name: "Updated" }]); + + const rootsChangeEvent = await rootsChangePromise; + expect(rootsChangeEvent.detail).toEqual([ + { uri: "file:///updated", name: "Updated" }, + ]); + + // Verify another notification was sent + const updatedRequests = server.getRecordedRequests(); + const secondNotification = updatedRequests.filter( + (req) => req.method === "notifications/roots/list_changed", + ); + expect(secondNotification.length).toBeGreaterThanOrEqual(1); + + await client!.disconnect(); + await server.stop(); + }); + }); + + describe("Completions", () => { + it("should get completions for resource template variable", async () => { + // Create a test server with a resource template that has completion support + const completionCallback = (argName: string, value: string): string[] => { + if (argName === "path") { + const files = ["file1.txt", "file2.txt", "file3.txt"]; + return files.filter((f) => f.startsWith(value)); + } + return []; + }; + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate(completionCallback)], + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Request completions for "file" variable with partial value "file1" + const result = await client.getCompletions( + { type: "ref/resource", uri: "file:///{path}" }, + "path", + "file1", + ); + + expect(result.values).toContain("file1.txt"); + expect(result.values.length).toBeGreaterThan(0); + + await client.disconnect(); + await server.stop(); + }); + + it("should get completions for prompt argument", async () => { + // Create a test server with a prompt that has completion support + const cityCompletions = (value: string): string[] => { + const cities = ["New York", "Los Angeles", "Chicago", "Houston"]; + return cities.filter((c) => + c.toLowerCase().startsWith(value.toLowerCase()), + ); + }; + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [ + createArgsPrompt({ + city: cityCompletions, + }), + ], + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Request completions for "city" argument with partial value "New" + const result = await client.getCompletions( + { type: "ref/prompt", name: "args_prompt" }, + "city", + "New", + ); + + expect(result.values).toContain("New York"); + expect(result.values.length).toBeGreaterThan(0); + + await client.disconnect(); + await server.stop(); + }); + + it("should return empty array when server does not support completions", async () => { + // Create a test server without completion support + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], // No completion callback + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Request completions - should return empty array (MethodNotFound handled gracefully) + const result = await client.getCompletions( + { type: "ref/resource", uri: "file:///{path}" }, + "path", + "file", + ); + + expect(result.values).toEqual([]); + + await client.disconnect(); + await server.stop(); + }); + + it("should get completions with context (other arguments)", async () => { + // Create a test server with a prompt that uses context + const stateCompletions = ( + value: string, + context?: Record, + ): string[] => { + const statesByCity: Record = { + "New York": ["NY", "New York State"], + "Los Angeles": ["CA", "California"], + }; + + const city = context?.city; + if (city && statesByCity[city]) { + return statesByCity[city].filter((s) => + s.toLowerCase().startsWith(value.toLowerCase()), + ); + } + return ["NY", "CA", "TX", "FL"].filter((s) => + s.toLowerCase().startsWith(value.toLowerCase()), + ); + }; + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [ + createArgsPrompt({ + state: stateCompletions, + }), + ], + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + // Request completions for "state" with context (city="New York") + const result = await client.getCompletions( + { type: "ref/prompt", name: "args_prompt" }, + "state", + "N", + { city: "New York" }, + ); + + expect(result.values).toContain("NY"); + expect(result.values).toContain("New York State"); + + await client.disconnect(); + await server.stop(); + }); + + it("should handle async completion callbacks", async () => { + // Create a test server with async completion callback + const asyncCompletionCallback = async ( + argName: string, + value: string, + ): Promise => { + // Simulate async I/O in completion callback; fixture behavior, not a test wait. + await new Promise((resolve) => setTimeout(resolve, 10)); + const files = ["async1.txt", "async2.txt", "async3.txt"]; + return files.filter((f) => f.startsWith(value)); + }; + + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [ + createFileResourceTemplate(asyncCompletionCallback), + ], + }); + + await server.start(); + + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + + await client.connect(); + + const result = await client.getCompletions( + { type: "ref/resource", uri: "file:///{path}" }, + "path", + "async1", + ); + + expect(result.values).toContain("async1.txt"); + + await client.disconnect(); + await server.stop(); + }); + }); + + describe("Task Support", () => { + beforeEach(async () => { + // Create server with task support + const taskConfig = { + ...getTaskServerConfig(), + serverType: "sse" as const, + }; + server = createTestServerHttp(taskConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + }); + + it("should detect task capabilities", () => { + const capabilities = client!.getTaskCapabilities(); + expect(capabilities).toBeDefined(); + expect(capabilities?.list).toBe(true); + expect(capabilities?.cancel).toBe(true); + }); + + it("should list tasks (empty initially)", async () => { + const result = await client!.listRequestorTasks(); + expect(result).toHaveProperty("tasks"); + expect(Array.isArray(result.tasks)).toBe(true); + }); + + it("should run tool as task (callTool with taskOptions returns task reference, poll getRequestorTask/getRequestorTaskResult yields result)", async () => { + // Same path as web App "Run as task": callTool with taskOptions -> task reference -> poll until completed + const optionalTaskTool = await getTool(client!, "optional_task"); + const invocation = await client!.callTool( + optionalTaskTool, + { message: "e2e-run-as-task" }, + undefined, + undefined, + { ttl: 5000 }, + ); + + expect(invocation.success).toBe(true); + expect(invocation.result).toBeDefined(); + expect(typeof invocation.result).toBe("object"); + const rawResult = invocation.result as Record; + expect(rawResult.task).toBeDefined(); + const taskRef = rawResult.task as { + taskId: string; + status: string; + pollInterval?: number; + }; + expect(taskRef.taskId).toBeDefined(); + expect(typeof taskRef.taskId).toBe("string"); + expect(taskRef.taskId.length).toBeGreaterThan(0); + expect(taskRef.status).toBeDefined(); + expect(typeof taskRef.status).toBe("string"); + + const taskId = taskRef.taskId; + const pollIntervalMs = taskRef.pollInterval ?? 1000; + const timeoutMs = 12000; + const start = Date.now(); + let task = await client!.getRequestorTask(taskId); + while ( + task.status !== "completed" && + task.status !== "failed" && + task.status !== "cancelled" + ) { + expect(Date.now() - start).toBeLessThan(timeoutMs); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + task = await client!.getRequestorTask(taskId); + } + + expect(task.status).toBe("completed"); + + const result = await client!.getRequestorTaskResult(taskId); + expect(result).toBeDefined(); + expect(result).toHaveProperty("content"); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content.length).toBe(1); + const firstContent = result.content[0]; + expect(firstContent).toBeDefined(); + expect(firstContent!.type).toBe("text"); + expect(firstContent!).toHaveProperty("text"); + const resultText = JSON.parse((firstContent as { text: string }).text); + expect(resultText.message).toBe("Task completed: e2e-run-as-task"); + expect(resultText.taskId).toBe(taskId); + + const listResult = await client!.listRequestorTasks(); + const found = listResult.tasks.some((t) => t.taskId === taskId); + expect(found).toBe(true); + }); + + it("should call tool with task support using callToolStream", async () => { + const toolCallTaskUpdatedEvents: Array<{ + taskId: string; + task: Task; + result?: CallToolResult; + error?: unknown; + }> = []; + const toolCallResultEvents: Array<{ + toolName: string; + params: Record; + result: CallToolResult | null; + timestamp: Date; + success: boolean; + error?: string; + metadata?: Record; + }> = []; + + client!.addEventListener( + "toolCallTaskUpdated", + (event: TypedEvent<"toolCallTaskUpdated">) => { + toolCallTaskUpdatedEvents.push(event.detail); + }, + ); + client!.addEventListener( + "toolCallResultChange", + (event: TypedEvent<"toolCallResultChange">) => { + toolCallResultEvents.push(event.detail); + }, + ); + + const simpleTaskTool = await getTool(client!, "simple_task"); + const result = await client!.callToolStream(simpleTaskTool, { + message: "test task", + }); + + // Validate final result + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).not.toBeNull(); + expect(result.result).toHaveProperty("content"); + + // Validate result content structure + const toolResult = result.result!; + expect(toolResult.content).toBeDefined(); + expect(Array.isArray(toolResult.content)).toBe(true); + expect(toolResult.content.length).toBe(1); + + const firstContent = toolResult.content[0]; + expect(firstContent).toBeDefined(); + expect(firstContent).not.toBeUndefined(); + expect(firstContent!.type).toBe("text"); + + // Validate result content value + if (firstContent && firstContent.type === "text") { + expect(firstContent.text).toBeDefined(); + const resultText = JSON.parse(firstContent.text); + expect(resultText.message).toBe("Task completed: test task"); + expect(resultText.taskId).toBeDefined(); + expect(typeof resultText.taskId).toBe("string"); + } else { + expect(firstContent?.type).toBe("text"); + } + + // Validate toolCallTaskUpdated events - first is task created, then status updates, last has result + expect(toolCallTaskUpdatedEvents.length).toBeGreaterThanOrEqual(1); + const createdEvent = toolCallTaskUpdatedEvents[0]!; + expect(createdEvent.taskId).toBeDefined(); + expect(typeof createdEvent.taskId).toBe("string"); + expect(createdEvent.task).toBeDefined(); + expect(createdEvent.task.taskId).toBe(createdEvent.taskId); + expect(createdEvent.task.status).toBe("working"); + expect(createdEvent.task).toHaveProperty("ttl"); + expect(createdEvent.task).toHaveProperty("lastUpdatedAt"); + + const taskId = createdEvent.taskId; + + // All events are for the same task and have valid structure + const statuses = toolCallTaskUpdatedEvents.map((event) => { + expect(event.taskId).toBe(taskId); + expect(event.task.taskId).toBe(taskId); + expect(event.task).toHaveProperty("status"); + expect(event.task).toHaveProperty("ttl"); + expect(event.task).toHaveProperty("lastUpdatedAt"); + if (event.task.lastUpdatedAt) { + expect(typeof event.task.lastUpdatedAt).toBe("string"); + expect(() => new Date(event.task.lastUpdatedAt!)).not.toThrow(); + } + return event.task.status; + }); + + expect(statuses[statuses.length - 1]).toBe("completed"); + statuses.forEach((status) => { + expect(["working", "completed"]).toContain(status); + }); + if (toolCallTaskUpdatedEvents.length > 1) { + expect(statuses[0]).toBe("working"); + expect(statuses[statuses.length - 1]).toBe("completed"); + } else { + expect(statuses[0]).toBe("completed"); + } + + // Last event must have result (completed) + const completedEvent = toolCallTaskUpdatedEvents.find( + (e) => e.result !== undefined, + )!; + expect(completedEvent).toBeDefined(); + expect(completedEvent.taskId).toBe(taskId); + expect(completedEvent.result).toBeDefined(); + expect(completedEvent.result).toEqual(toolResult); + + // Validate toolCallResultChange event + expect(toolCallResultEvents.length).toBe(1); + const toolCallEvent = toolCallResultEvents[0]!; + expect(toolCallEvent.toolName).toBe("simple_task"); + expect(toolCallEvent.params).toEqual({ message: "test task" }); + expect(toolCallEvent.success).toBe(true); + expect(toolCallEvent.result).toEqual(toolResult); + expect(toolCallEvent.timestamp).toBeInstanceOf(Date); + + // Validate task in requestor tasks (from server list) + const { tasks: requestorTasks } = await client!.listRequestorTasks(); + const cachedTask = requestorTasks.find((t) => t.taskId === taskId); + expect(cachedTask).toBeDefined(); + expect(cachedTask!.taskId).toBe(taskId); + expect(cachedTask!.status).toBe("completed"); + expect(cachedTask!).toHaveProperty("ttl"); + expect(cachedTask!).toHaveProperty("lastUpdatedAt"); + + // Validate consistency: taskId from all sources matches + expect(createdEvent.taskId).toBe(taskId); + expect(completedEvent.taskId).toBe(taskId); + expect(cachedTask!.taskId).toBe(taskId); + if (firstContent && firstContent.type === "text") { + const resultText = JSON.parse(firstContent.text); + expect(resultText.taskId).toBe(taskId); + } + }); + + it("should accept taskOptions (ttl) in callToolStream", async () => { + const simpleTaskTtlTool = await getTool(client!, "simple_task"); + const result = await client!.callToolStream( + simpleTaskTtlTool, + { message: "ttl-test" }, + undefined, + undefined, + { ttl: 99999 }, + ); + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + const { tasks } = await client!.listRequestorTasks(); + const task = tasks.find((t) => t.taskId && t.status === "completed"); + expect(task).toBeDefined(); + expect(task).toHaveProperty("ttl"); + }); + + it("should get task by taskId", async () => { + // First create a task + const simpleTaskByIdTool = await getTool(client!, "simple_task"); + const result = await client!.callToolStream(simpleTaskByIdTool, { + message: "test", + }); + expect(result.success).toBe(true); + + // Get the taskId from server task list + const { tasks: activeTasks } = await client!.listRequestorTasks(); + expect(activeTasks.length).toBeGreaterThan(0); + const activeTask = activeTasks[0]; + expect(activeTask).toBeDefined(); + const taskId = activeTask!.taskId; + + // Get the task + const task = await client!.getRequestorTask(taskId); + expect(task).toBeDefined(); + expect(task.taskId).toBe(taskId); + expect(task.status).toBe("completed"); + }); + + it("should get task result", async () => { + // First create a task + const simpleTaskResultTool = await getTool(client!, "simple_task"); + const result = await client!.callToolStream(simpleTaskResultTool, { + message: "test result", + }); + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).not.toBeNull(); + + // Get the taskId from server task list + const { tasks: requestorTasks } = await client!.listRequestorTasks(); + expect(requestorTasks.length).toBeGreaterThan(0); + const task = requestorTasks.find((t) => t.status === "completed"); + expect(task).toBeDefined(); + const taskId = task!.taskId; + + // Get the task result + const taskResult = await client!.getRequestorTaskResult(taskId); + + // Validate result structure + expect(taskResult).toBeDefined(); + expect(taskResult).toHaveProperty("content"); + expect(Array.isArray(taskResult.content)).toBe(true); + expect(taskResult.content.length).toBe(1); + + // Validate content structure + const firstContent = taskResult.content[0]; + expect(firstContent).toBeDefined(); + expect(firstContent).not.toBeUndefined(); + expect(firstContent!.type).toBe("text"); + + // Validate content value + if (firstContent && firstContent.type === "text") { + expect(firstContent.text).toBeDefined(); + const resultText = JSON.parse(firstContent.text); + expect(resultText.message).toBe("Task completed: test result"); + expect(resultText.taskId).toBe(taskId); + } else { + expect(firstContent?.type).toBe("text"); + } + + // Validate that getTaskResult returns the same result as callToolStream + expect(taskResult).toEqual(result.result); + }); + + it("should throw error when calling callTool on task-required tool", async () => { + const simpleTaskRequiredTool = await getTool(client!, "simple_task"); + await expect( + client!.callTool(simpleTaskRequiredTool, { message: "test" }), + ).rejects.toThrow("requires task support"); + }); + + it("should clear tasks on disconnect", async () => { + // Create a task + const simpleTaskDisconnectTool = await getTool(client!, "simple_task"); + await client!.callToolStream(simpleTaskDisconnectTool, { + message: "test", + }); + const listBefore = await client!.listRequestorTasks(); + expect(listBefore.tasks.length).toBeGreaterThan(0); + + // Disconnect + await client!.disconnect(); + + // After disconnect we cannot list tasks (not connected); test that client is disconnected + expect(client!.getStatus()).toBe("disconnected"); + }); + + it("should call tool with taskSupport: forbidden (immediate result, no task)", async () => { + // forbiddenTask should return immediately without creating a task + const forbiddenTaskTool = await getTool(client!, "forbidden_task"); + const result = await client!.callToolStream(forbiddenTaskTool, { + message: "test", + }); + + expect(result.success).toBe(true); + expect(result.result).toHaveProperty("content"); + // No task should be created (forbidden_task returns immediately) + const { tasks } = await client!.listRequestorTasks(); + expect(tasks.length).toBe(0); + }); + + it("should call tool with taskSupport: optional (may or may not create task)", async () => { + // optionalTask may create a task or return immediately + const optionalTaskStreamTool = await getTool(client!, "optional_task"); + const result = await client!.callToolStream(optionalTaskStreamTool, { + message: "test", + }); + + expect(result.success).toBe(true); + expect(result.result).toHaveProperty("content"); + // Task may or may not be created - both are valid + }); + + it("should handle task failure and dispatch taskFailed event", async () => { + await client!.disconnect(); + await server?.stop(); + + // Create a task tool that will fail after a short delay + const failingTask = createTaskTool({ + name: "failingTask", + delayMs: 100, + failAfterDelay: 50, // Fail after 50ms + }); + + const taskConfig = getTaskServerConfig(); + const failConfig = { + ...taskConfig, + serverType: "sse" as const, + tools: [failingTask, ...(taskConfig.tools || [])], + }; + server = createTestServerHttp(failConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client!.connect(); + + const failedPromise = expect( + (async () => { + const failingTaskTool = await getTool(client!, "failingTask"); + return client!.callToolStream(failingTaskTool, { message: "test" }); + })(), + ).rejects.toThrow(); + + const taskFailedDetail = await new Promise<{ + taskId: string; + task: Task; + error: unknown; + }>((resolve, reject) => { + const timeout = setTimeout( + () => + reject( + new Error("Timeout waiting for toolCallTaskUpdated with error"), + ), + 2000, + ); + const handler = ( + e: Event & { + detail: { taskId: string; task: Task; error?: unknown }; + }, + ) => { + if (e.detail.error !== undefined) { + clearTimeout(timeout); + client!.removeEventListener("toolCallTaskUpdated", handler); + resolve(e.detail); + } + }; + client!.addEventListener("toolCallTaskUpdated", handler); + }); + expect(taskFailedDetail.taskId).toBeDefined(); + expect(taskFailedDetail.error).toBeDefined(); + + await failedPromise; + }); + + it("should cancel a running task", async () => { + await client!.disconnect(); + await server?.stop(); + + // Create a longer-running task tool + const longRunningTask = createTaskTool({ + name: "longRunningTask", + delayMs: 2000, // 2 seconds + }); + + const taskConfig = getTaskServerConfig(); + const cancelConfig = { + ...taskConfig, + serverType: "sse" as const, + tools: [longRunningTask, ...(taskConfig.tools || [])], + }; + server = createTestServerHttp(cancelConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + }, + ); + await client!.connect(); + + const longRunningTaskTool = await getTool(client!, "longRunningTask"); + const taskPromise = client!.callToolStream(longRunningTaskTool, { + message: "test", + }); + + const taskCreatedDetail = await waitForEvent<{ + taskId: string; + task: Task; + }>(client, "toolCallTaskUpdated", { timeout: 3000 }); + const taskId = taskCreatedDetail.taskId; + expect(taskId).toBeDefined(); + + const cancelledPromise = waitForEvent<{ taskId: string }>( + client, + "taskCancelled", + { timeout: 3000 }, + ); + await client!.cancelRequestorTask(taskId); + + const [cancelledResult, taskResult] = await Promise.allSettled([ + cancelledPromise, + taskPromise, + ]); + expect(cancelledResult.status).toBe("fulfilled"); + const cancelledDetail = ( + cancelledResult as PromiseFulfilledResult<{ taskId: string }> + ).value; + expect(cancelledDetail.taskId).toBe(taskId); + expect(taskResult.status).toBe("rejected"); + + const task = await client!.getRequestorTask(taskId); + expect(task.status).toBe("cancelled"); + }); + + it("should handle elicitation with task (input_required flow)", async () => { + await client!.disconnect(); + await server?.stop(); + + const elicitationConfig = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createElicitationTaskTool("taskWithElicitation"), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(elicitationConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + elicit: true, + }, + ); + await client.connect(); + + const elicitationPromise = waitForEvent( + client, + "newPendingElicitation", + { timeout: 2000 }, + ); + const taskWithElicitationTool = await getTool( + client, + "taskWithElicitation", + ); + const taskPromise = client.callToolStream(taskWithElicitationTool, { + message: "test", + }); + + const elicitation = await elicitationPromise; + + // Verify elicitation was received + expect(elicitation).toBeDefined(); + + // Verify task status is input_required (if taskId was extracted) + if (elicitation.taskId) { + const { tasks: activeTasks } = await client.listRequestorTasks(); + const task = activeTasks.find((t) => t.taskId === elicitation.taskId); + if (task) { + expect(task.status).toBe("input_required"); + } + } + + // Respond to elicitation with correct format + await elicitation.respond({ + action: "accept", + content: { + input: "test input", + }, + }); + + // Wait for task to complete + const result = await taskPromise; + expect(result.success).toBe(true); + }); + + it("should handle sampling with task (input_required flow)", async () => { + await client!.disconnect(); + await server?.stop(); + + const samplingConfig = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createSamplingTaskTool("taskWithSampling"), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(samplingConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + sample: true, + }, + ); + await client!.connect(); + + const samplingPromise = waitForEvent( + client, + "newPendingSample", + { timeout: 3000 }, + ); + const taskCreatedPromise = waitForEvent<{ taskId: string; task: Task }>( + client, + "toolCallTaskUpdated", + { timeout: 3000 }, + ); + const taskWithSamplingTool = await getTool(client!, "taskWithSampling"); + const taskPromise = client!.callToolStream(taskWithSamplingTool, { + message: "test", + }); + + const sample = await samplingPromise; + expect(sample).toBeDefined(); + + const taskCreatedDetail = await taskCreatedPromise; + const task = await client!.getRequestorTask(taskCreatedDetail.taskId); + expect(task).toBeDefined(); + expect(task!.status).toBe("input_required"); + + // Respond to sampling with correct format + await sample.respond({ + model: "test-model", + role: "assistant", + stopReason: "endTurn", + content: { + type: "text", + text: "Sampling response", + }, + }); + + // Wait for task to complete + const result = await taskPromise; + expect(result.success).toBe(true); + }); + + it("should handle progress notifications linked to tasks", async () => { + await client!.disconnect(); + await server?.stop(); + + // createProgressTaskTool defaults to 5 progress units with 2000ms delay + // Progress notifications are sent at delayMs / progressUnits intervals (400ms each) + const progressConfig = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createProgressTaskTool("taskWithProgress", 2000, 5), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(progressConfig); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + progress: true, + }, + ); + await client!.connect(); + + const progressToken = Math.random().toString(); + + const taskCreatedPromise = waitForEvent<{ taskId: string; task: Task }>( + client, + "toolCallTaskUpdated", + { timeout: 5000 }, + ); + const progressPromise = waitForProgressCount(client!, 5, { + timeout: 5000, + }); + const taskWithProgressTool = await getTool(client!, "taskWithProgress"); + const resultPromise = client!.callToolStream( + taskWithProgressTool, + { message: "test" }, + undefined, + { progressToken }, + ); + + const taskCreatedDetail = await taskCreatedPromise; + const taskId = taskCreatedDetail.taskId; + expect(taskId).toBeDefined(); + + const taskCompletedDetail = await new Promise<{ + taskId: string; + task: Task; + result: unknown; + }>((resolve, reject) => { + const timeout = setTimeout( + () => + reject( + new Error("Timeout waiting for toolCallTaskUpdated with result"), + ), + 5000, + ); + const handler = ( + e: Event & { + detail: { taskId: string; task: Task; result?: unknown }; + }, + ) => { + if (e.detail.result !== undefined) { + clearTimeout(timeout); + client!.removeEventListener("toolCallTaskUpdated", handler); + resolve(e.detail); + } + }; + client!.addEventListener("toolCallTaskUpdated", handler); + }); + + const progressEvents = await progressPromise; + const result = await resultPromise; + + // Verify task completed successfully + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).not.toBeNull(); + expect(result.result).toHaveProperty("content"); + + // Validate the actual tool call response content + const toolResult = result.result!; + expect(toolResult.content).toBeDefined(); + expect(Array.isArray(toolResult.content)).toBe(true); + expect(toolResult.content.length).toBe(1); + + const firstContent = toolResult.content[0]; + expect(firstContent).toBeDefined(); + expect(firstContent).not.toBeUndefined(); + expect(firstContent!.type).toBe("text"); + + // Assert it's a text content block (for TypeScript narrowing) + expect(firstContent!.type === "text").toBe(true); + + // TypeScript type narrowing - we've already asserted it's text + if (firstContent && firstContent.type === "text") { + expect(firstContent.text).toBeDefined(); + // Parse and validate the JSON text content + const resultText = JSON.parse(firstContent.text); + expect(resultText.message).toBe("Task completed: test"); + expect(resultText.taskId).toBe(taskId); + } else { + // This should never happen due to the assertion above, but TypeScript needs it + expect(firstContent?.type).toBe("text"); + } + + expect(taskCompletedDetail.taskId).toBe(taskId); + expect(taskCompletedDetail.result).toBeDefined(); + expect(taskCompletedDetail.result).toEqual(toolResult); + + expect(progressEvents.length).toBe(5); + progressEvents.forEach((evt: unknown, index: number) => { + const event = evt as { + progressToken: string; + progress: number; + total: number; + message: string; + _meta?: Record; + }; + expect(event.progressToken).toBe(progressToken); + expect(event.progress).toBe(index + 1); + expect(event.total).toBe(5); + expect(event.message).toBe(`Processing... ${index + 1}/5`); + expect(event._meta).toBeDefined(); + expect(event._meta?.[RELATED_TASK_META_KEY]).toBeDefined(); + const relatedTask = event._meta?.[RELATED_TASK_META_KEY] as { + taskId: string; + }; + expect(relatedTask.taskId).toBe(taskId); + }); + + // Verify task is in completed state (from server list) + const { tasks: activeTasks } = await client!.listRequestorTasks(); + const completedTask = activeTasks.find((t) => t.taskId === taskId); + expect(completedTask).toBeDefined(); + expect(completedTask!.status).toBe("completed"); + }); + + it("should handle listTasks pagination", async () => { + const simpleTaskPaginationTool = await getTool(client!, "simple_task"); + await client!.callToolStream(simpleTaskPaginationTool, { + message: "task1", + }); + await client!.callToolStream(simpleTaskPaginationTool, { + message: "task2", + }); + await client!.callToolStream(simpleTaskPaginationTool, { + message: "task3", + }); + const result = await client!.listRequestorTasks(); + expect(result.tasks.length).toBeGreaterThan(0); + + // If there's a nextCursor, test pagination + if (result.nextCursor) { + const nextPage = await client!.listRequestorTasks(result.nextCursor); + expect(nextPage.tasks).toBeDefined(); + expect(Array.isArray(nextPage.tasks)).toBe(true); + } + }); + }); + + describe("Receiver tasks (e2e)", () => { + it("server sends createMessage with params.task, client returns task, test responds, server gets payload via tasks/get and tasks/result", async () => { + if (client) await client.disconnect(); + client = null; + await server?.stop(); + + const config = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createTaskTool({ + name: "receiverE2ESampling", + samplingText: "Reply for e2e", + receiverTaskTtl: 5000, + }), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(config); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + sample: true, + receiverTasks: true, + receiverTaskTtlMs: 10_000, + }, + ); + await client.connect(); + + const samplingPromise = waitForEvent( + client, + "newPendingSample", + { timeout: 5000 }, + ); + const receiverE2ESamplingTool = await getTool( + client, + "receiverE2ESampling", + ); + const taskPromise = client.callToolStream(receiverE2ESamplingTool, { + message: "e2e", + }); + + const sample = await samplingPromise; + expect(sample).toBeDefined(); + + await sample.respond({ + model: "e2e-model", + role: "assistant", + stopReason: "endTurn", + content: { type: "text", text: "E2E receiver response" }, + }); + + const result = await taskPromise; + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).not.toBeNull(); + expect(result.result!.content).toBeDefined(); + const content = result.result!.content!; + const textBlock = Array.isArray(content) ? content[0] : content; + expect(textBlock).toBeDefined(); + expect( + textBlock && + typeof textBlock === "object" && + "type" in textBlock && + textBlock.type === "text", + ).toBe(true); + if (textBlock && typeof textBlock === "object" && "text" in textBlock) { + expect((textBlock as { text: string }).text).toBe( + "E2E receiver response", + ); + } + }); + + it("server sends elicit with params.task, client returns task, test responds, server gets payload via tasks/get and tasks/result", async () => { + if (client) await client.disconnect(); + client = null; + await server?.stop(); + + const config = { + ...getTaskServerConfig(), + serverType: "sse" as const, + tools: [ + createTaskTool({ + name: "receiverE2EElicit", + elicitationSchema: z.object({ + input: z.string().describe("User input"), + }), + receiverTaskTtl: 5000, + }), + ...(getTaskServerConfig().tools || []), + ], + }; + server = createTestServerHttp(config); + await server.start(); + client = new InspectorClient( + { + type: "sse", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + elicit: true, + receiverTasks: true, + receiverTaskTtlMs: 10_000, + }, + ); + await client.connect(); + + const elicitationPromise = waitForEvent( + client, + "newPendingElicitation", + { timeout: 5000 }, + ); + const receiverE2EElicitTool = await getTool(client, "receiverE2EElicit"); + const taskPromise = client.callToolStream(receiverE2EElicitTool, { + message: "e2e", + }); + + const elicitation = await elicitationPromise; + expect(elicitation).toBeDefined(); + + await elicitation.respond({ + action: "accept", + content: { input: "E2E elicitation input" }, + }); + + const result = await taskPromise; + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result).not.toBeNull(); + expect(result.result!.content).toBeDefined(); + // Elicit payload from tasks/result is JSON in a text block + const content = result.result!.content!; + const textBlock = Array.isArray(content) ? content[0] : content; + expect( + textBlock && typeof textBlock === "object" && "text" in textBlock, + ).toBe(true); + const parsed = JSON.parse((textBlock as { text: string }).text) as Record< + string, + unknown + >; + expect(parsed.input).toBe("E2E elicitation input"); + }); + }); +}); diff --git a/core/__tests__/jsonUtils.test.ts b/core/__tests__/jsonUtils.test.ts new file mode 100644 index 000000000..ea5050c66 --- /dev/null +++ b/core/__tests__/jsonUtils.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + convertParameterValue, + convertToolParameters, + convertPromptArguments, +} from "../json/jsonUtils.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +describe("JSON Utils", () => { + describe("convertParameterValue", () => { + it("should convert string to string", () => { + expect(convertParameterValue("hello", { type: "string" })).toBe("hello"); + }); + + it("should convert string to number", () => { + expect(convertParameterValue("42", { type: "number" })).toBe(42); + expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14); + }); + + it("should convert string to boolean", () => { + expect(convertParameterValue("true", { type: "boolean" })).toBe(true); + expect(convertParameterValue("false", { type: "boolean" })).toBe(false); + }); + + it("should parse JSON strings", () => { + expect( + convertParameterValue('{"key":"value"}', { type: "object" }), + ).toEqual({ + key: "value", + }); + expect(convertParameterValue("[1,2,3]", { type: "array" })).toEqual([ + 1, 2, 3, + ]); + }); + + it("should return string for unknown types", () => { + expect(convertParameterValue("hello", { type: "unknown" })).toBe("hello"); + }); + }); + + describe("convertToolParameters", () => { + const tool: Tool = { + name: "test-tool", + description: "Test tool", + inputSchema: { + type: "object", + properties: { + message: { type: "string" }, + count: { type: "number" }, + enabled: { type: "boolean" }, + }, + }, + }; + + it("should convert string parameters", () => { + const result = convertToolParameters(tool, { + message: "hello", + count: "42", + enabled: "true", + }); + + expect(result.message).toBe("hello"); + expect(result.count).toBe(42); + expect(result.enabled).toBe(true); + }); + + it("should preserve non-string values", () => { + const result = convertToolParameters(tool, { + message: "hello", + count: "42", // Still pass as string, conversion will handle it + enabled: "true", // Still pass as string, conversion will handle it + }); + + expect(result.message).toBe("hello"); + expect(result.count).toBe(42); + expect(result.enabled).toBe(true); + }); + + it("should handle missing schema", () => { + const toolWithoutSchema: Tool = { + name: "test-tool", + description: "Test tool", + inputSchema: { + type: "object", + properties: {}, + }, + }; + + const result = convertToolParameters(toolWithoutSchema, { + message: "hello", + }); + + expect(result.message).toBe("hello"); + }); + }); + + describe("convertPromptArguments", () => { + it("should convert values to strings", () => { + const result = convertPromptArguments({ + name: "John", + age: 42, + active: true, + data: { key: "value" }, + items: [1, 2, 3], + }); + + expect(result.name).toBe("John"); + expect(result.age).toBe("42"); + expect(result.active).toBe("true"); + expect(result.data).toBe('{"key":"value"}'); + expect(result.items).toBe("[1,2,3]"); + }); + + it("should handle null and undefined", () => { + const result = convertPromptArguments({ + value: null, + missing: undefined, + }); + + expect(result.value).toBe("null"); + expect(result.missing).toBe("undefined"); + }); + }); +}); diff --git a/core/__tests__/mcp/oauthManager.test.ts b/core/__tests__/mcp/oauthManager.test.ts new file mode 100644 index 000000000..8f7d7efe4 --- /dev/null +++ b/core/__tests__/mcp/oauthManager.test.ts @@ -0,0 +1,224 @@ +/** + * OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and + * dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens, + * error propagation, and getOAuthState/getOAuthStep after beginGuidedAuth. + */ +import { describe, it, expect, vi } from "vitest"; +import { + OAuthManager, + type OAuthManagerConfig, + type OAuthManagerParams, +} from "../../mcp/oauthManager.js"; + +const SERVER_URL = "https://example.com/mcp"; + +function createMockParams( + overrides?: Partial, +): OAuthManagerParams { + const dispatchOAuthStepChange = vi.fn(); + const dispatchOAuthComplete = vi.fn(); + const dispatchOAuthAuthorizationRequired = vi.fn(); + const dispatchOAuthError = vi.fn(); + + const storage = { + getScope: vi.fn().mockResolvedValue(undefined), + getClientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn().mockResolvedValue(undefined), + savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined), + saveScope: vi.fn().mockResolvedValue(undefined), + getTokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn().mockResolvedValue(undefined), + getCodeVerifier: vi.fn().mockReturnValue("verifier"), + saveCodeVerifier: vi.fn().mockResolvedValue(undefined), + clear: vi.fn(), + clearClientInformation: vi.fn(), + clearTokens: vi.fn(), + clearCodeVerifier: vi.fn(), + clearScope: vi.fn(), + clearServerMetadata: vi.fn(), + getServerMetadata: vi.fn().mockReturnValue(null), + saveServerMetadata: vi.fn().mockResolvedValue(undefined), + }; + + const redirectUrlProvider = { + getRedirectUrl: vi.fn().mockReturnValue("http://localhost/callback"), + }; + + const navigation = { + navigateToAuthorization: vi.fn(), + }; + + const initialConfig: OAuthManagerConfig = { + storage, + redirectUrlProvider, + navigation, + clientId: "test-client", + clientSecret: "test-secret", + }; + + return { + getServerUrl: vi.fn().mockReturnValue(SERVER_URL), + effectiveAuthFetch: vi.fn().mockResolvedValue(new Response("{}")), + getEventTarget: vi.fn().mockReturnValue(new EventTarget()), + initialConfig, + dispatchOAuthStepChange, + dispatchOAuthComplete, + dispatchOAuthAuthorizationRequired, + dispatchOAuthError, + ...overrides, + }; +} + +describe("OAuthManager", () => { + describe("setOAuthConfig", () => { + it("merges config without throwing", () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + expect(() => { + manager.setOAuthConfig({ scope: "read write" }); + manager.setOAuthConfig({ clientId: "new-id" }); + }).not.toThrow(); + }); + }); + + describe("getServerUrl propagation", () => { + it("createOAuthProviderForTransport throws when getServerUrl throws", async () => { + const params = createMockParams({ + getServerUrl: vi.fn().mockImplementation(() => { + throw new Error("OAuth is only supported for HTTP-based transports"); + }), + }); + const manager = new OAuthManager(params); + await expect(manager.createOAuthProviderForTransport()).rejects.toThrow( + "OAuth is only supported for HTTP-based transports", + ); + }); + }); + + describe("clearOAuthTokens", () => { + it("calls storage.clear(serverUrl) when storage is configured", () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + manager.clearOAuthTokens(); + expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith( + SERVER_URL, + ); + expect(manager.getOAuthState()).toBeUndefined(); + expect(manager.getOAuthStep()).toBeUndefined(); + }); + + it("no-ops when storage is not configured", () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi.fn().mockReturnValue("http://localhost"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + manager.clearOAuthTokens(); + expect(params.getServerUrl).not.toHaveBeenCalled(); + }); + }); + + describe("getOAuthState / getOAuthStep", () => { + it("returns undefined before any flow", () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + expect(manager.getOAuthState()).toBeUndefined(); + expect(manager.getOAuthStep()).toBeUndefined(); + }); + }); + + describe("dispatch callbacks", () => { + it("completeOAuthFlow calls dispatchOAuthError when normal path throws", async () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + // Normal path (no guided state): auth() will run and fail (no real server), so catch calls dispatchOAuthError + await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow(); + expect(params.dispatchOAuthError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + }), + ); + }); + }); + + describe("getOAuthTokens", () => { + it("returns undefined when not authorized", async () => { + const params = createMockParams(); + ( + params.initialConfig.storage as unknown as { + getTokens: ReturnType; + } + ).getTokens.mockResolvedValue(undefined); + const manager = new OAuthManager(params); + const tokens = await manager.getOAuthTokens(); + expect(tokens).toBeUndefined(); + }); + + it("returns tokens from storage when no in-memory state", async () => { + const params = createMockParams(); + const storedTokens = { + access_token: "stored-token", + token_type: "Bearer", + }; + ( + params.initialConfig.storage as unknown as { + getTokens: ReturnType; + } + ).getTokens.mockResolvedValue(storedTokens); + const manager = new OAuthManager(params); + const tokens = await manager.getOAuthTokens(); + expect(tokens).toEqual(storedTokens); + }); + }); + + describe("isOAuthAuthorized", () => { + it("returns false when getOAuthTokens returns undefined", async () => { + const params = createMockParams(); + ( + params.initialConfig.storage as unknown as { + getTokens: ReturnType; + } + ).getTokens.mockResolvedValue(undefined); + const manager = new OAuthManager(params); + expect(await manager.isOAuthAuthorized()).toBe(false); + }); + + it("returns true when getOAuthTokens returns tokens", async () => { + const params = createMockParams(); + ( + params.initialConfig.storage as unknown as { + getTokens: ReturnType; + } + ).getTokens.mockResolvedValue({ + access_token: "x", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + expect(await manager.isOAuthAuthorized()).toBe(true); + }); + }); + + describe("setGuidedAuthorizationCode", () => { + it("throws when not in guided flow", async () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + await expect( + manager.setGuidedAuthorizationCode("code", true), + ).rejects.toThrow("Not in guided OAuth flow"); + }); + }); + + describe("proceedOAuthStep", () => { + it("throws when not in guided flow", async () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + await expect(manager.proceedOAuthStep()).rejects.toThrow( + "Not in guided OAuth flow", + ); + }); + }); +}); diff --git a/core/__tests__/mcp/state/fetchRequestLogState.test.ts b/core/__tests__/mcp/state/fetchRequestLogState.test.ts new file mode 100644 index 000000000..377893275 --- /dev/null +++ b/core/__tests__/mcp/state/fetchRequestLogState.test.ts @@ -0,0 +1,176 @@ +/** + * FetchRequestLogState tests use a mock protocol that dispatches "fetchRequest" + * and "saveSession" to assert the manager's list, events, and session save/restore. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { FetchRequestEntry } from "../../../mcp/types.js"; +import type { InspectorClientSessionState } from "../../../mcp/sessionStorage.js"; +import { FetchRequestLogState } from "../../../mcp/state/fetchRequestLogState.js"; + +class MockFetchRequestProtocol extends EventTarget { + dispatchFetchRequest(entry: FetchRequestEntry): void { + this.dispatchEvent(new CustomEvent("fetchRequest", { detail: entry })); + } + + dispatchSaveSession(sessionId: string): void { + this.dispatchEvent( + new CustomEvent("saveSession", { detail: { sessionId } }), + ); + } +} + +function createFetchRequestEntry(id: string): FetchRequestEntry { + return { + id: `fetch-${id}`, + timestamp: new Date(), + method: "GET", + url: `https://example.com/${id}`, + requestHeaders: {}, + category: "transport", + }; +} + +type InspectorClient = + import("../../../mcp/inspectorClient.js").InspectorClient; + +describe("FetchRequestLogState", () => { + let protocol: MockFetchRequestProtocol; + let state: FetchRequestLogState | null = null; + + afterEach(() => { + state?.destroy(); + state = null; + }); + + it("starts with empty fetch requests", () => { + protocol = new MockFetchRequestProtocol(); + state = new FetchRequestLogState(protocol as unknown as InspectorClient); + expect(state.getFetchRequests()).toEqual([]); + }); + + it("on protocol fetchRequest appends entry and dispatches fetchRequest + fetchRequestsChange", () => { + protocol = new MockFetchRequestProtocol(); + state = new FetchRequestLogState(protocol as unknown as InspectorClient); + const entry = createFetchRequestEntry("1"); + + const singleDetails: FetchRequestEntry[] = []; + const listDetails: FetchRequestEntry[][] = []; + state.addEventListener("fetchRequest", (e) => singleDetails.push(e.detail)); + state.addEventListener("fetchRequestsChange", (e) => + listDetails.push(e.detail), + ); + + protocol.dispatchFetchRequest(entry); + + expect(state.getFetchRequests()).toHaveLength(1); + expect(state.getFetchRequests()[0]).toBe(entry); + expect(singleDetails).toHaveLength(1); + expect(singleDetails[0]).toBe(entry); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toHaveLength(1); + }); + + it("maxFetchRequests option trims oldest when at capacity", () => { + protocol = new MockFetchRequestProtocol(); + state = new FetchRequestLogState(protocol as unknown as InspectorClient, { + maxFetchRequests: 3, + }); + protocol.dispatchFetchRequest(createFetchRequestEntry("1")); + protocol.dispatchFetchRequest(createFetchRequestEntry("2")); + protocol.dispatchFetchRequest(createFetchRequestEntry("3")); + expect(state.getFetchRequests()).toHaveLength(3); + protocol.dispatchFetchRequest(createFetchRequestEntry("4")); + expect(state.getFetchRequests()).toHaveLength(3); + expect(state.getFetchRequests().map((r) => r.id)).toEqual([ + "fetch-2", + "fetch-3", + "fetch-4", + ]); + }); + + it("clearFetchRequests() empties list and dispatches fetchRequestsChange only when non-empty", () => { + protocol = new MockFetchRequestProtocol(); + state = new FetchRequestLogState(protocol as unknown as InspectorClient); + const listDetails: FetchRequestEntry[][] = []; + state.addEventListener("fetchRequestsChange", (e) => + listDetails.push(e.detail), + ); + state.clearFetchRequests(); + expect(listDetails).toHaveLength(0); + + protocol.dispatchFetchRequest(createFetchRequestEntry("1")); + expect(listDetails).toHaveLength(1); + state.clearFetchRequests(); + expect(state.getFetchRequests()).toEqual([]); + expect(listDetails).toHaveLength(2); + expect(listDetails[1]).toEqual([]); + }); + + it("destroy() unsubscribes and clears state", () => { + protocol = new MockFetchRequestProtocol(); + state = new FetchRequestLogState(protocol as unknown as InspectorClient); + protocol.dispatchFetchRequest(createFetchRequestEntry("1")); + state.destroy(); + expect(state.getFetchRequests()).toEqual([]); + protocol.dispatchFetchRequest(createFetchRequestEntry("2")); + expect(state.getFetchRequests()).toEqual([]); + }); + + it("with sessionStorage + sessionId, saveSession event triggers save with current fetch requests", async () => { + protocol = new MockFetchRequestProtocol(); + let savedState: InspectorClientSessionState | undefined; + const sessionStorage = { + saveSession: vi.fn(async (id: string, s: InspectorClientSessionState) => { + savedState = s; + }), + loadSession: vi.fn(async () => undefined), + deleteSession: vi.fn(async () => {}), + }; + state = new FetchRequestLogState(protocol as unknown as InspectorClient, { + sessionStorage, + sessionId: "test-session", + }); + protocol.dispatchFetchRequest(createFetchRequestEntry("1")); + protocol.dispatchSaveSession("test-session"); + await vi.waitFor(() => { + expect(sessionStorage.saveSession).toHaveBeenCalledWith("test-session", { + fetchRequests: expect.any(Array), + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + }); + }); + expect(savedState!.fetchRequests).toHaveLength(1); + expect(savedState!.fetchRequests[0].id).toBe("fetch-1"); + }); + + it("with sessionStorage + sessionId, restores fetch requests from loadSession on creation", async () => { + protocol = new MockFetchRequestProtocol(); + const restored = [ + { + ...createFetchRequestEntry("restored-1"), + id: "restored-1", + timestamp: new Date(1000), + }, + ]; + const sessionStorage = { + saveSession: vi.fn(async () => {}), + loadSession: vi.fn(async () => ({ + fetchRequests: restored, + createdAt: 1000, + updatedAt: 1000, + })), + deleteSession: vi.fn(async () => {}), + }; + state = new FetchRequestLogState(protocol as unknown as InspectorClient, { + sessionStorage, + sessionId: "test-session", + }); + await vi.waitFor( + () => { + expect(state!.getFetchRequests()).toHaveLength(1); + expect(state!.getFetchRequests()[0].id).toBe("restored-1"); + }, + { timeout: 500, interval: 20 }, + ); + }); +}); diff --git a/core/__tests__/mcp/state/managedPromptsState.test.ts b/core/__tests__/mcp/state/managedPromptsState.test.ts new file mode 100644 index 000000000..1261c655f --- /dev/null +++ b/core/__tests__/mcp/state/managedPromptsState.test.ts @@ -0,0 +1,169 @@ +/** + * ManagedPromptsState tests use a real InspectorClient and test server (same model + * as managedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { ManagedPromptsState } from "../../../mcp/state/managedPromptsState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createSimplePrompt, + createNumberedPrompts, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("ManagedPromptsState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: ManagedPromptsState | null = null; + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + function waitForPromptsChange(s: ManagedPromptsState): Promise { + return new Promise((resolve) => { + s.addEventListener("promptsChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty prompts before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new ManagedPromptsState(client); + expect(state.getPrompts()).toEqual([]); + }); + + it("on connect loads initial prompts and dispatches promptsChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedPromptsState(client); + const promptsPromise = waitForPromptsChange(state); + await client.connect(); + const prompts = await promptsPromise; + expect(prompts.length).toBeGreaterThan(0); + expect(state.getPrompts()).toEqual(prompts); + }); + + it("refresh fetches all pages and dispatches promptsChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { prompts: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedPromptsState(client); + const promptsPromise = waitForPromptsChange(state); + const prompts = await state.refresh(); + await promptsPromise; + expect(prompts).toHaveLength(6); + expect(state.getPrompts()).toEqual(prompts); + }); + + it("on promptsListChanged refreshes and updates prompts", async () => { + const { createAddPromptTool } = + await import("@modelcontextprotocol/inspector-test-server"); + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + tools: [createAddPromptTool()], + listChanged: { prompts: true }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedPromptsState(client); + await client.connect(); + await waitForPromptsChange(state!); + const listResult = await client!.listTools(); + const addPromptTool = listResult.tools.find((t) => t.name === "add_prompt"); + expect(addPromptTool).toBeDefined(); + const promptsChangePromise = waitForPromptsChange(state!); + await client!.callTool(addPromptTool!, { + name: "newPrompt", + promptString: "This is a new prompt", + }); + await promptsChangePromise; + expect(state!.getPrompts().some((p) => p.name === "newPrompt")).toBe(true); + }); + + it("on disconnect clears prompts", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedPromptsState(client); + await client.connect(); + await waitForPromptsChange(state!); + expect(state!.getPrompts().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getPrompts()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedPromptsState(client); + await state.refresh(); + expect(state.getPrompts().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getPrompts()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/managedRequestorTasksState.test.ts b/core/__tests__/mcp/state/managedRequestorTasksState.test.ts new file mode 100644 index 000000000..39025b329 --- /dev/null +++ b/core/__tests__/mcp/state/managedRequestorTasksState.test.ts @@ -0,0 +1,207 @@ +/** + * ManagedRequestorTasksState tests use a mock InspectorClient to test refresh, + * event subscriptions (connect, tasksListChanged, taskStatusChange, requestorTaskUpdated, taskCancelled, statusChange), and destroy. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import { ManagedRequestorTasksState } from "../../../mcp/state/managedRequestorTasksState.js"; +import type { InspectorClient } from "../../../mcp/inspectorClient.js"; + +function createMockTask(overrides: Partial & { taskId: string }): Task { + return { + ...overrides, + taskId: overrides.taskId, + ttl: overrides.ttl ?? null, + status: overrides.status ?? "working", + statusMessage: overrides.statusMessage ?? "", + lastUpdatedAt: overrides.lastUpdatedAt ?? new Date().toISOString(), + createdAt: overrides.createdAt ?? new Date().toISOString(), + }; +} + +function createMockClient( + listTasksImpl: ( + cursor?: string, + ) => Promise<{ tasks: Task[]; nextCursor?: string }>, +): InspectorClient { + const eventTarget = new EventTarget(); + return { + getStatus: vi.fn().mockReturnValue("connected"), + listRequestorTasks: vi.fn().mockImplementation(listTasksImpl), + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + } as unknown as InspectorClient; +} + +describe("ManagedRequestorTasksState", () => { + let state: ManagedRequestorTasksState | null = null; + + afterEach(() => { + if (state) { + state.destroy(); + state = null; + } + }); + + function waitForTasksChange(s: ManagedRequestorTasksState): Promise { + return new Promise((resolve) => { + s.addEventListener( + "tasksChange", + (e: Event) => resolve((e as CustomEvent).detail), + { + once: true, + }, + ); + }); + } + + it("starts with empty tasks", () => { + const client = createMockClient(async () => ({ tasks: [] })); + state = new ManagedRequestorTasksState(client); + expect(state.getTasks()).toEqual([]); + }); + + it("refresh loads all pages and dispatches tasksChange", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const task2 = createMockTask({ taskId: "t2" }); + const client = createMockClient(async (cursor) => { + if (cursor === undefined) return { tasks: [task1], nextCursor: "c1" }; + return { tasks: [task2], nextCursor: undefined }; + }); + state = new ManagedRequestorTasksState(client); + + const tasksPromise = waitForTasksChange(state); + const result = await state.refresh(); + await tasksPromise; + + expect(result).toHaveLength(2); + expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1", "t2"]); + }); + + it("connect triggers refresh", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent(new CustomEvent("connect")); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.taskId).toBe("t1"); + }); + + it("tasksListChanged triggers refresh", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent(new CustomEvent("tasksListChanged")); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + }); + + it("taskStatusChange merges task into list", async () => { + const task1 = createMockTask({ taskId: "t1", status: "pending" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + await state.refresh(); + + const updated = createMockTask({ taskId: "t1", status: "completed" }); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("taskStatusChange", { + detail: { taskId: "t1", task: updated }, + }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.status).toBe("completed"); + }); + + it("requestorTaskUpdated merges new task at head", async () => { + const client = createMockClient(async () => ({ + tasks: [], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + await state.refresh(); + + const newTask = createMockTask({ taskId: "new1", status: "working" }); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("requestorTaskUpdated", { + detail: { taskId: "new1", task: newTask }, + }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.taskId).toBe("new1"); + }); + + it("taskCancelled updates task status in list", async () => { + const task1 = createMockTask({ taskId: "t1", status: "working" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + await state.refresh(); + + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("taskCancelled", { detail: { taskId: "t1" } }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.status).toBe("cancelled"); + }); + + it("statusChange to disconnected clears tasks", async () => { + const client = createMockClient(async () => ({ + tasks: [createMockTask({ taskId: "t1" })], + nextCursor: undefined, + })); + (client.getStatus as ReturnType).mockReturnValue("connected"); + state = new ManagedRequestorTasksState(client); + await state.refresh(); + expect(state.getTasks()).toHaveLength(1); + + (client.getStatus as ReturnType).mockReturnValue( + "disconnected", + ); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("statusChange", { detail: "disconnected" }), + ); + await tasksPromise; + + expect(state.getTasks()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + const client = createMockClient(async () => ({ + tasks: [createMockTask({ taskId: "t1" })], + nextCursor: undefined, + })); + state = new ManagedRequestorTasksState(client); + await state.refresh(); + state.destroy(); + expect(state.getTasks()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/managedResourceTemplatesState.test.ts b/core/__tests__/mcp/state/managedResourceTemplatesState.test.ts new file mode 100644 index 000000000..deb0e8211 --- /dev/null +++ b/core/__tests__/mcp/state/managedResourceTemplatesState.test.ts @@ -0,0 +1,140 @@ +/** + * ManagedResourceTemplatesState tests use a real InspectorClient and test server (same model + * as managedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { ManagedResourceTemplatesState } from "../../../mcp/state/managedResourceTemplatesState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createFileResourceTemplate, + createNumberedResourceTemplates, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("ManagedResourceTemplatesState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: ManagedResourceTemplatesState | null = null; + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + function waitForResourceTemplatesChange( + s: ManagedResourceTemplatesState, + ): Promise { + return new Promise((resolve) => { + s.addEventListener("resourceTemplatesChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty resource templates before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new ManagedResourceTemplatesState(client); + expect(state.getResourceTemplates()).toEqual([]); + }); + + it("on connect loads initial resource templates and dispatches resourceTemplatesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedResourceTemplatesState(client); + const templatesPromise = waitForResourceTemplatesChange(state); + await client.connect(); + const templates = await templatesPromise; + expect(templates.length).toBeGreaterThan(0); + expect(state.getResourceTemplates()).toEqual(templates); + }); + + it("refresh fetches all pages and dispatches resourceTemplatesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(6), + maxPageSize: { resourceTemplates: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedResourceTemplatesState(client); + const templatesPromise = waitForResourceTemplatesChange(state); + const templates = await state.refresh(); + await templatesPromise; + expect(templates).toHaveLength(6); + expect(state.getResourceTemplates()).toEqual(templates); + }); + + it("on disconnect clears resource templates", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedResourceTemplatesState(client); + await client.connect(); + await waitForResourceTemplatesChange(state!); + expect(state!.getResourceTemplates().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getResourceTemplates()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedResourceTemplatesState(client); + await state.refresh(); + expect(state.getResourceTemplates().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getResourceTemplates()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/managedResourcesState.test.ts b/core/__tests__/mcp/state/managedResourcesState.test.ts new file mode 100644 index 000000000..622810c37 --- /dev/null +++ b/core/__tests__/mcp/state/managedResourcesState.test.ts @@ -0,0 +1,177 @@ +/** + * ManagedResourcesState tests use a real InspectorClient and test server (same model + * as managedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { ManagedResourcesState } from "../../../mcp/state/managedResourcesState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createArchitectureResource, + createNumberedResources, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("ManagedResourcesState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: ManagedResourcesState | null = null; + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + function waitForResourcesChange( + s: ManagedResourcesState, + ): Promise { + return new Promise((resolve) => { + s.addEventListener("resourcesChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty resources before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new ManagedResourcesState(client); + expect(state.getResources()).toEqual([]); + }); + + it("on connect loads initial resources and dispatches resourcesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedResourcesState(client); + const resourcesPromise = waitForResourcesChange(state); + await client.connect(); + const resources = await resourcesPromise; + expect(resources.length).toBeGreaterThan(0); + expect(state.getResources()).toEqual(resources); + }); + + it("refresh fetches all pages and dispatches resourcesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + await client.connect(); + state = new ManagedResourcesState(client); + const resourcesPromise = waitForResourcesChange(state); + const resources = await state.refresh(); + await resourcesPromise; + expect(resources).toHaveLength(6); + expect(state.getResources()).toEqual(resources); + }); + + it("on resourcesListChanged refreshes and updates resources", async () => { + const { createAddResourceTool } = + await import("@modelcontextprotocol/inspector-test-server"); + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + tools: [createAddResourceTool()], + listChanged: { resources: true }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedResourcesState(client); + await client.connect(); + await waitForResourcesChange(state!); + const listResult = await client!.listTools(); + const addResourceTool = listResult.tools.find( + (t) => t.name === "add_resource", + ); + expect(addResourceTool).toBeDefined(); + const resourcesChangePromise = waitForResourcesChange(state!); + await client!.callTool(addResourceTool!, { + uri: "test://new-resource", + name: "newResource", + text: "New resource content", + }); + await resourcesChangePromise; + expect( + state!.getResources().some((r) => r.uri === "test://new-resource"), + ).toBe(true); + }); + + it("on disconnect clears resources", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedResourcesState(client); + await client.connect(); + await waitForResourcesChange(state!); + expect(state!.getResources().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getResources()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedResourcesState(client); + await state.refresh(); + expect(state.getResources().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getResources()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/managedToolsState.test.ts b/core/__tests__/mcp/state/managedToolsState.test.ts new file mode 100644 index 000000000..d4de3181d --- /dev/null +++ b/core/__tests__/mcp/state/managedToolsState.test.ts @@ -0,0 +1,187 @@ +/** + * ManagedToolsState tests use a real InspectorClient and test server (same model + * as inspectorClient.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { ManagedToolsState } from "../../../mcp/state/managedToolsState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createEchoTool, + createNumberedTools, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("ManagedToolsState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: ManagedToolsState | null = null; + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + function waitForToolsChange(s: ManagedToolsState): Promise { + return new Promise((resolve) => { + s.addEventListener("toolsChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty tools before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new ManagedToolsState(client); + expect(state.getTools()).toEqual([]); + }); + + it("on connect loads initial tools and dispatches toolsChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedToolsState(client); + const toolsPromise = waitForToolsChange(state); + await client.connect(); + const tools = await toolsPromise; + expect(tools.length).toBeGreaterThan(0); + expect(tools.some((t) => t.name === "echo")).toBe(true); + expect(state.getTools()).toEqual(tools); + }); + + it("refresh fetches all pages and dispatches toolsChange", async () => { + // Same server config as inspectorClient.test "should accumulate tools when paginating with cursor" + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(6), + maxPageSize: { tools: 2 }, + }); + await server.start(); + client = new InspectorClient( + { + type: "streamable-http", + url: server.url, + }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + await client.connect(); + + // Manager refresh must see exactly 6 tools (uses listTools(), so no list interactions) + state = new ManagedToolsState(client); + const toolsPromise = waitForToolsChange(state); + const tools = await state.refresh(); + await toolsPromise; + expect(tools).toHaveLength(6); + expect(tools.map((t) => t.name)).toEqual([ + "tool_1", + "tool_2", + "tool_3", + "tool_4", + "tool_5", + "tool_6", + ]); + expect(state.getTools()).toEqual(tools); + }); + + it("on toolsListChanged refreshes and updates tools", async () => { + const { createAddToolTool } = + await import("@modelcontextprotocol/inspector-test-server"); + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool(), createAddToolTool()], + listChanged: { tools: true }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedToolsState(client); + await client.connect(); + await waitForToolsChange(state!); + const toolsBefore = state!.getTools(); + expect(toolsBefore.length).toBeGreaterThan(0); + + const addTool = state!.getTools().find((t) => t.name === "add_tool"); + expect(addTool).toBeDefined(); + const toolsChangePromise = waitForToolsChange(state!); + await client!.callTool(addTool!, { + name: "newTool", + description: "A new test tool", + }); + await toolsChangePromise; + const toolsAfter = state!.getTools(); + expect(toolsAfter.find((t) => t.name === "newTool")).toBeDefined(); + }); + + it("on disconnect clears tools", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new ManagedToolsState(client); + await client.connect(); + await waitForToolsChange(state!); + expect(state!.getTools().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getTools()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new ManagedToolsState(client); + await state.refresh(); + expect(state.getTools().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getTools()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/messageLogState.test.ts b/core/__tests__/mcp/state/messageLogState.test.ts new file mode 100644 index 000000000..4193d810b --- /dev/null +++ b/core/__tests__/mcp/state/messageLogState.test.ts @@ -0,0 +1,226 @@ +/** + * MessageLogState tests use a mock protocol that dispatches "message" and "statusChange" + * to assert the manager's list and emitted events. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { MessageEntry } from "../../../mcp/types.js"; +import { MessageLogState } from "../../../mcp/state/messageLogState.js"; + +/** Minimal protocol shape: dispatches "message", "statusChange", "connect"; getStatus(). */ +class MockMessageProtocol extends EventTarget { + private _status: "connected" | "disconnected" = "connected"; + + getStatus(): "connected" | "disconnected" { + return this._status; + } + + dispatchMessage(entry: MessageEntry): void { + this.dispatchEvent(new CustomEvent("message", { detail: entry })); + } + + dispatchConnect(): void { + this.dispatchEvent(new CustomEvent("connect")); + } + + setDisconnected(): void { + this._status = "disconnected"; + this.dispatchEvent( + new CustomEvent("statusChange", { detail: "disconnected" }), + ); + } +} + +function createRequestEntry(id: string): MessageEntry { + return { + id: `req-${id}`, + timestamp: new Date(), + direction: "request", + message: { jsonrpc: "2.0", id: 1, method: "test" }, + }; +} + +function createResponseEntry(messageId: number): MessageEntry { + return { + id: `res-${messageId}`, + timestamp: new Date(), + direction: "response", + message: { jsonrpc: "2.0", id: messageId, result: {} }, + }; +} + +describe("MessageLogState", () => { + let protocol: MockMessageProtocol; + let state: MessageLogState | null = null; + + afterEach(() => { + state?.destroy(); + state = null; + }); + + it("starts with empty messages", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + expect(state.getMessages()).toEqual([]); + }); + + it("on protocol message appends entry and dispatches message + messagesChange", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + const entry = createRequestEntry("1"); + + const messageDetails: MessageEntry[] = []; + const listDetails: MessageEntry[][] = []; + state.addEventListener("message", (e) => messageDetails.push(e.detail)); + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + + protocol.dispatchMessage(entry); + + expect(state.getMessages()).toHaveLength(1); + expect(state.getMessages()[0]).toBe(entry); + expect(messageDetails).toHaveLength(1); + expect(messageDetails[0]).toBe(entry); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toHaveLength(1); + }); + + it("when protocol dispatches request then response with same id, manager matches and does not duplicate, still emits events", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + const requestEntry = createRequestEntry("1"); + const responseEntry = createResponseEntry(1); + + const listDetails: MessageEntry[][] = []; + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + + protocol.dispatchMessage(requestEntry); + protocol.dispatchMessage(responseEntry); + + expect(state.getMessages()).toHaveLength(1); + expect(state.getMessages()[0].response).toEqual(responseEntry.message); + expect(state.getMessages()[0].duration).toBeDefined(); + expect(listDetails).toHaveLength(2); + }); + + it("clearMessages() empties list and dispatches messagesChange", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + expect(state.getMessages()).toHaveLength(1); + + const listDetails: MessageEntry[][] = []; + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + state.clearMessages(); + + expect(state.getMessages()).toEqual([]); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toEqual([]); + }); + + it("on statusChange disconnected clears list and dispatches messagesChange", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + expect(state.getMessages()).toHaveLength(1); + + const listDetails: MessageEntry[][] = []; + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + protocol.setDisconnected(); + + expect(state.getMessages()).toEqual([]); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toEqual([]); + }); + + it("destroy() unsubscribes and clears state", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + state.destroy(); + expect(state.getMessages()).toEqual([]); + // After destroy, protocol messages should not be appended + protocol.dispatchMessage(createRequestEntry("2")); + expect(state.getMessages()).toEqual([]); + }); + + it("maxMessages option trims oldest when at capacity", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + { + maxMessages: 3, + }, + ); + protocol.dispatchMessage(createRequestEntry("1")); + protocol.dispatchMessage(createRequestEntry("2")); + protocol.dispatchMessage(createRequestEntry("3")); + expect(state.getMessages()).toHaveLength(3); + protocol.dispatchMessage(createRequestEntry("4")); + expect(state.getMessages()).toHaveLength(3); + expect(state.getMessages().map((m) => m.id)).toEqual([ + "req-2", + "req-3", + "req-4", + ]); + }); + + it("getMessages(predicate) returns filtered list", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + protocol.dispatchMessage(createRequestEntry("2")); + protocol.dispatchMessage(createRequestEntry("3")); + const requests = state.getMessages((e) => e.direction === "request"); + expect(requests).toHaveLength(3); + const one = state.getMessages((e) => e.id === "req-2"); + expect(one).toHaveLength(1); + expect(one[0]!.id).toBe("req-2"); + }); + + it("clearMessages(predicate) removes only matching and dispatches only when changed", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + protocol.dispatchMessage(createRequestEntry("2")); + protocol.dispatchMessage(createRequestEntry("3")); + const listDetails: MessageEntry[][] = []; + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + state.clearMessages((e) => e.id === "req-2"); + expect(state.getMessages()).toHaveLength(2); + expect(state.getMessages().map((m) => m.id)).toEqual(["req-1", "req-3"]); + expect(listDetails).toHaveLength(1); + state.clearMessages((e) => e.id === "nonexistent"); + expect(state.getMessages()).toHaveLength(2); + expect(listDetails).toHaveLength(1); + }); + + it("on connect clears list and dispatches messagesChange", () => { + protocol = new MockMessageProtocol(); + state = new MessageLogState( + protocol as unknown as import("../../../mcp/inspectorClient.js").InspectorClient, + ); + protocol.dispatchMessage(createRequestEntry("1")); + expect(state.getMessages()).toHaveLength(1); + const listDetails: MessageEntry[][] = []; + state.addEventListener("messagesChange", (e) => listDetails.push(e.detail)); + protocol.dispatchConnect(); + expect(state.getMessages()).toEqual([]); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/pagedPromptsState.test.ts b/core/__tests__/mcp/state/pagedPromptsState.test.ts new file mode 100644 index 000000000..3e4eda513 --- /dev/null +++ b/core/__tests__/mcp/state/pagedPromptsState.test.ts @@ -0,0 +1,205 @@ +/** + * PagedPromptsState tests use a real InspectorClient and test server (same model + * as pagedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { PagedPromptsState } from "../../../mcp/state/pagedPromptsState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createSimplePrompt, + createNumberedPrompts, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("PagedPromptsState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: PagedPromptsState | null = null; + + let unhandledRejectionHandler: ( + reason: unknown, + promise: Promise, + ) => void; + beforeEach(() => { + unhandledRejectionHandler = ( + reason: unknown, + promise: Promise, + ) => { + const err = reason as { code?: number; message?: string }; + if (err?.code === -32000 || err?.message?.includes("Connection closed")) { + promise.catch(() => {}); + return; + } + throw reason; + }; + process.on("unhandledRejection", unhandledRejectionHandler); + }); + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + process.off("unhandledRejection", unhandledRejectionHandler); + }); + + function waitForPromptsChange(s: PagedPromptsState): Promise { + return new Promise((resolve) => { + s.addEventListener("promptsChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty prompts before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new PagedPromptsState(client); + expect(state.getPrompts()).toEqual([]); + }); + + it("does not load prompts on connect", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedPromptsState(client); + await client.connect(); + expect(state!.getPrompts()).toEqual([]); + }); + + it("loadPage(undefined) loads first page and returns nextCursor when server has more", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { prompts: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedPromptsState(client); + + const promptsPromise = waitForPromptsChange(state); + const result = await state.loadPage(); + await promptsPromise; + + expect(result.prompts).toHaveLength(2); + expect(result.nextCursor).toBeDefined(); + expect(state.getPrompts()).toHaveLength(2); + }); + + it("loadPage(cursor) loads next page and appends to aggregated list", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: createNumberedPrompts(6), + maxPageSize: { prompts: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedPromptsState(client); + + let result = await state.loadPage(); + expect(state.getPrompts().length).toBeGreaterThanOrEqual(1); + + while (result.nextCursor) { + result = await state.loadPage(result.nextCursor); + } + expect(state.getPrompts()).toHaveLength(6); + }); + + it("on disconnect clears prompts", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedPromptsState(client); + await client.connect(); + await state.loadPage(); + expect(state.getPrompts().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getPrompts()).toEqual([]); + }); + + it("clear empties the list and dispatches promptsChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedPromptsState(client); + await state.loadPage(); + expect(state.getPrompts().length).toBeGreaterThan(0); + + const promptsPromise = waitForPromptsChange(state); + state.clear(); + const prompts = await promptsPromise; + expect(prompts).toEqual([]); + expect(state.getPrompts()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + prompts: [createSimplePrompt()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedPromptsState(client); + await state.loadPage(); + expect(state.getPrompts().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getPrompts()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/pagedRequestorTasksState.test.ts b/core/__tests__/mcp/state/pagedRequestorTasksState.test.ts new file mode 100644 index 000000000..268805dad --- /dev/null +++ b/core/__tests__/mcp/state/pagedRequestorTasksState.test.ts @@ -0,0 +1,240 @@ +/** + * PagedRequestorTasksState tests use a mock InspectorClient to test loadPage, + * clear, event subscriptions (tasksListChanged, taskStatusChange, requestorTaskUpdated, taskCancelled, statusChange), and destroy. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import { PagedRequestorTasksState } from "../../../mcp/state/pagedRequestorTasksState.js"; +import type { InspectorClient } from "../../../mcp/inspectorClient.js"; + +function createMockTask(overrides: Partial & { taskId: string }): Task { + return { + ...overrides, + taskId: overrides.taskId, + ttl: overrides.ttl ?? null, + status: overrides.status ?? "working", + statusMessage: overrides.statusMessage ?? "", + lastUpdatedAt: overrides.lastUpdatedAt ?? new Date().toISOString(), + createdAt: overrides.createdAt ?? new Date().toISOString(), + }; +} + +function createMockClient( + listTasksImpl: ( + cursor?: string, + ) => Promise<{ tasks: Task[]; nextCursor?: string }>, +): InspectorClient { + const eventTarget = new EventTarget(); + const addEventListener = eventTarget.addEventListener.bind(eventTarget); + const removeEventListener = eventTarget.removeEventListener.bind(eventTarget); + const dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); + return { + getStatus: vi.fn().mockReturnValue("connected"), + listRequestorTasks: vi.fn().mockImplementation(listTasksImpl), + addEventListener, + removeEventListener, + dispatchEvent, + } as unknown as InspectorClient; +} + +describe("PagedRequestorTasksState", () => { + let state: PagedRequestorTasksState | null = null; + + afterEach(() => { + if (state) { + state.destroy(); + state = null; + } + }); + + function waitForTasksChange(s: PagedRequestorTasksState): Promise { + return new Promise((resolve) => { + s.addEventListener( + "tasksChange", + (e: Event) => resolve((e as CustomEvent).detail), + { + once: true, + }, + ); + }); + } + + it("starts with empty tasks", () => { + const client = createMockClient(async () => ({ tasks: [] })); + state = new PagedRequestorTasksState(client); + expect(state.getTasks()).toEqual([]); + expect(state.getNextCursor()).toBeUndefined(); + }); + + it("loadPage(undefined) loads first page and sets nextCursor", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const client = createMockClient(async (cursor) => { + if (cursor === undefined) { + return { tasks: [task1], nextCursor: "cursor2" }; + } + return { tasks: [], nextCursor: undefined }; + }); + state = new PagedRequestorTasksState(client); + + const tasksPromise = waitForTasksChange(state); + const result = await state.loadPage(); + await tasksPromise; + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.taskId).toBe("t1"); + expect(result.nextCursor).toBe("cursor2"); + expect(state.getTasks()).toHaveLength(1); + expect(state.getNextCursor()).toBe("cursor2"); + }); + + it("loadPage(cursor) appends and updates nextCursor", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const task2 = createMockTask({ taskId: "t2" }); + const client = createMockClient(async (cursor) => { + if (cursor === undefined) return { tasks: [task1], nextCursor: "c1" }; + return { tasks: [task2], nextCursor: undefined }; + }); + state = new PagedRequestorTasksState(client); + + await state.loadPage(); + const tasksPromise = waitForTasksChange(state); + const result = await state.loadPage("c1"); + await tasksPromise; + + expect(result.tasks).toHaveLength(1); + expect(state.getTasks()).toHaveLength(2); + expect(state.getTasks().map((t) => t.taskId)).toEqual(["t1", "t2"]); + expect(state.getNextCursor()).toBeUndefined(); + }); + + it("clear empties list and dispatches tasksChange", async () => { + const client = createMockClient(async () => ({ + tasks: [createMockTask({ taskId: "t1" })], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + expect(state.getTasks()).toHaveLength(1); + + const tasksPromise = waitForTasksChange(state); + state.clear(); + const tasks = await tasksPromise; + + expect(tasks).toEqual([]); + expect(state.getTasks()).toEqual([]); + expect(state.getNextCursor()).toBeUndefined(); + }); + + it("taskStatusChange merges task into list", async () => { + const task1 = createMockTask({ taskId: "t1", status: "pending" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + + const updated = createMockTask({ taskId: "t1", status: "working" }); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("taskStatusChange", { + detail: { taskId: "t1", task: updated }, + }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.status).toBe("working"); + }); + + it("requestorTaskUpdated merges new task at head", async () => { + const client = createMockClient(async () => ({ + tasks: [], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + + const newTask = createMockTask({ taskId: "new1", status: "working" }); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("requestorTaskUpdated", { + detail: { taskId: "new1", task: newTask }, + }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.taskId).toBe("new1"); + }); + + it("taskCancelled updates task status in list", async () => { + const task1 = createMockTask({ taskId: "t1", status: "working" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("taskCancelled", { detail: { taskId: "t1" } }), + ); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.status).toBe("cancelled"); + }); + + it("tasksListChanged triggers loadPage(undefined)", async () => { + const task1 = createMockTask({ taskId: "t1" }); + const client = createMockClient(async () => ({ + tasks: [task1], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent(new CustomEvent("tasksListChanged")); + await tasksPromise; + + expect(state.getTasks()).toHaveLength(1); + expect(state.getTasks()[0]?.taskId).toBe("t1"); + }); + + it("statusChange to disconnected clears tasks", async () => { + const client = createMockClient(async () => ({ + tasks: [createMockTask({ taskId: "t1" })], + nextCursor: undefined, + })); + (client.getStatus as ReturnType).mockReturnValue("connected"); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + expect(state.getTasks()).toHaveLength(1); + + (client.getStatus as ReturnType).mockReturnValue( + "disconnected", + ); + const tasksPromise = waitForTasksChange(state); + client.dispatchEvent( + new CustomEvent("statusChange", { detail: "disconnected" }), + ); + await tasksPromise; + + expect(state.getTasks()).toEqual([]); + expect(state.getNextCursor()).toBeUndefined(); + }); + + it("destroy unsubscribes and clears state", async () => { + const client = createMockClient(async () => ({ + tasks: [createMockTask({ taskId: "t1" })], + nextCursor: undefined, + })); + state = new PagedRequestorTasksState(client); + await state.loadPage(); + state.destroy(); + expect(state.getTasks()).toEqual([]); + expect(state.getNextCursor()).toBeUndefined(); + }); +}); diff --git a/core/__tests__/mcp/state/pagedResourceTemplatesState.test.ts b/core/__tests__/mcp/state/pagedResourceTemplatesState.test.ts new file mode 100644 index 000000000..4d9aae196 --- /dev/null +++ b/core/__tests__/mcp/state/pagedResourceTemplatesState.test.ts @@ -0,0 +1,207 @@ +/** + * PagedResourceTemplatesState tests use a real InspectorClient and test server (same model + * as pagedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { PagedResourceTemplatesState } from "../../../mcp/state/pagedResourceTemplatesState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createFileResourceTemplate, + createNumberedResourceTemplates, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("PagedResourceTemplatesState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: PagedResourceTemplatesState | null = null; + + let unhandledRejectionHandler: ( + reason: unknown, + promise: Promise, + ) => void; + beforeEach(() => { + unhandledRejectionHandler = ( + reason: unknown, + promise: Promise, + ) => { + const err = reason as { code?: number; message?: string }; + if (err?.code === -32000 || err?.message?.includes("Connection closed")) { + promise.catch(() => {}); + return; + } + throw reason; + }; + process.on("unhandledRejection", unhandledRejectionHandler); + }); + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + process.off("unhandledRejection", unhandledRejectionHandler); + }); + + function waitForResourceTemplatesChange( + s: PagedResourceTemplatesState, + ): Promise { + return new Promise((resolve) => { + s.addEventListener("resourceTemplatesChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty resource templates before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new PagedResourceTemplatesState(client); + expect(state.getResourceTemplates()).toEqual([]); + }); + + it("does not load resource templates on connect", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedResourceTemplatesState(client); + await client.connect(); + expect(state!.getResourceTemplates()).toEqual([]); + }); + + it("loadPage(undefined) loads first page and returns nextCursor when server has more", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(6), + maxPageSize: { resourceTemplates: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourceTemplatesState(client); + + const templatesPromise = waitForResourceTemplatesChange(state); + const result = await state.loadPage(); + await templatesPromise; + + expect(result.resourceTemplates).toHaveLength(2); + expect(result.nextCursor).toBeDefined(); + expect(state.getResourceTemplates()).toHaveLength(2); + }); + + it("loadPage(cursor) loads next page and appends to aggregated list", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: createNumberedResourceTemplates(6), + maxPageSize: { resourceTemplates: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedResourceTemplatesState(client); + + let result = await state.loadPage(); + expect(state.getResourceTemplates().length).toBeGreaterThanOrEqual(1); + + while (result.nextCursor) { + result = await state.loadPage(result.nextCursor); + } + expect(state.getResourceTemplates()).toHaveLength(6); + }); + + it("on disconnect clears resource templates", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedResourceTemplatesState(client); + await client.connect(); + await state.loadPage(); + expect(state.getResourceTemplates().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getResourceTemplates()).toEqual([]); + }); + + it("clear empties the list and dispatches resourceTemplatesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourceTemplatesState(client); + await state.loadPage(); + expect(state.getResourceTemplates().length).toBeGreaterThan(0); + + const templatesPromise = waitForResourceTemplatesChange(state); + state.clear(); + const templates = await templatesPromise; + expect(templates).toEqual([]); + expect(state.getResourceTemplates()).toEqual([]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resourceTemplates: [createFileResourceTemplate()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourceTemplatesState(client); + await state.loadPage(); + expect(state.getResourceTemplates().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getResourceTemplates()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/pagedResourcesState.test.ts b/core/__tests__/mcp/state/pagedResourcesState.test.ts new file mode 100644 index 000000000..cba1c97f1 --- /dev/null +++ b/core/__tests__/mcp/state/pagedResourcesState.test.ts @@ -0,0 +1,228 @@ +/** + * PagedResourcesState tests use a real InspectorClient and test server (same model + * as pagedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { PagedResourcesState } from "../../../mcp/state/pagedResourcesState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createArchitectureResource, + createNumberedResources, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("PagedResourcesState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: PagedResourcesState | null = null; + + let unhandledRejectionHandler: ( + reason: unknown, + promise: Promise, + ) => void; + beforeEach(() => { + unhandledRejectionHandler = ( + reason: unknown, + promise: Promise, + ) => { + const err = reason as { code?: number; message?: string }; + if (err?.code === -32000 || err?.message?.includes("Connection closed")) { + promise.catch(() => {}); + return; + } + throw reason; + }; + process.on("unhandledRejection", unhandledRejectionHandler); + }); + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + process.off("unhandledRejection", unhandledRejectionHandler); + }); + + function waitForResourcesChange(s: PagedResourcesState): Promise { + return new Promise((resolve) => { + s.addEventListener("resourcesChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty resources before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new PagedResourcesState(client); + expect(state.getResources()).toEqual([]); + }); + + it("does not load resources on connect", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedResourcesState(client); + await client.connect(); + expect(state!.getResources()).toEqual([]); + }); + + it("loadPage(undefined) loads first page and returns nextCursor when server has more", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourcesState(client); + + const resourcesPromise = waitForResourcesChange(state); + const result = await state.loadPage(); + await resourcesPromise; + + expect(result.resources).toHaveLength(2); + expect(result.nextCursor).toBeDefined(); + expect(state.getResources()).toHaveLength(2); + }); + + it("loadPage(cursor) loads next page and appends to aggregated list", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(6), + maxPageSize: { resources: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedResourcesState(client); + + let result = await state.loadPage(); + expect(state.getResources().length).toBeGreaterThanOrEqual(1); + + while (result.nextCursor) { + result = await state.loadPage(result.nextCursor); + } + expect(state.getResources()).toHaveLength(6); + }); + + it("on disconnect clears resources", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedResourcesState(client); + await client.connect(); + await state.loadPage(); + expect(state.getResources().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getResources()).toEqual([]); + }); + + it("clear empties the list and dispatches resourcesChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourcesState(client); + await state.loadPage(); + expect(state.getResources().length).toBeGreaterThan(0); + + const resourcesPromise = waitForResourcesChange(state); + state.clear(); + const resources = await resourcesPromise; + expect(resources).toEqual([]); + expect(state.getResources()).toEqual([]); + }); + + it("after clear, loadPage(undefined) reloads from first page", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: createNumberedResources(4), + maxPageSize: { resources: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourcesState(client); + await state.loadPage(); + expect(state.getResources()).toHaveLength(2); + state.clear(); + expect(state.getResources()).toEqual([]); + await state.loadPage(); + expect(state.getResources()).toHaveLength(2); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + resources: [createArchitectureResource()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedResourcesState(client); + await state.loadPage(); + expect(state.getResources().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getResources()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/pagedToolsState.test.ts b/core/__tests__/mcp/state/pagedToolsState.test.ts new file mode 100644 index 000000000..76fdb4240 --- /dev/null +++ b/core/__tests__/mcp/state/pagedToolsState.test.ts @@ -0,0 +1,211 @@ +/** + * PagedToolsState tests use a real InspectorClient and test server (same model + * as managedToolsState.test.ts) so we exercise actual client/server behavior. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "../../../mcp/inspectorClient.js"; +import { createTransportNode } from "../../../mcp/node/transport.js"; +import { PagedToolsState } from "../../../mcp/state/pagedToolsState.js"; +import { + createTestServerHttp, + createTestServerInfo, + createEchoTool, + createNumberedTools, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +describe("PagedToolsState", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + let state: PagedToolsState | null = null; + + afterEach(async () => { + if (state) { + state.destroy(); + state = null; + } + if (client) await client.disconnect(100); + client = null; + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + function waitForToolsChange(s: PagedToolsState): Promise { + return new Promise((resolve) => { + s.addEventListener("toolsChange", (e) => resolve(e.detail), { + once: true, + }); + }); + } + + it("starts with empty tools before connect", () => { + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:0" }, + { environment: { transport: createTransportNode } }, + ); + state = new PagedToolsState(client); + expect(state.getTools()).toEqual([]); + }); + + it("does not load tools on connect", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + state = new PagedToolsState(client); + await client.connect(); + // No waitForToolsChange — we expect no toolsChange on connect + expect(state!.getTools()).toEqual([]); + }); + + it("loadPage(undefined) loads first page and returns nextCursor when server has more", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(6), + maxPageSize: { tools: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + }, + ); + await client.connect(); + state = new PagedToolsState(client); + + const toolsPromise = waitForToolsChange(state); + const result = await state.loadPage(); + await toolsPromise; + + expect(result.tools).toHaveLength(2); + expect(result.nextCursor).toBeDefined(); + expect(state.getTools()).toHaveLength(2); + expect(state.getTools().map((t) => t.name)).toEqual(["tool_1", "tool_2"]); + }); + + it("loadPage(cursor) loads next page and appends to aggregated list", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(6), + maxPageSize: { tools: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedToolsState(client); + + let result = await state.loadPage(); + expect(state.getTools().length).toBeGreaterThanOrEqual(1); + + while (result.nextCursor) { + result = await state.loadPage(result.nextCursor); + } + expect(state.getTools()).toHaveLength(6); + expect(state.getTools().map((t) => t.name)).toEqual([ + "tool_1", + "tool_2", + "tool_3", + "tool_4", + "tool_5", + "tool_6", + ]); + }); + + it("on disconnect clears tools", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + state = new PagedToolsState(client); + await client.connect(); + await state.loadPage(); + expect(state.getTools().length).toBeGreaterThan(0); + await client!.disconnect(100); + expect(state!.getTools()).toEqual([]); + }); + + it("clear empties the list and dispatches toolsChange", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedToolsState(client); + await state.loadPage(); + expect(state.getTools().length).toBeGreaterThan(0); + + const toolsPromise = waitForToolsChange(state); + state.clear(); + const tools = await toolsPromise; + expect(tools).toEqual([]); + expect(state.getTools()).toEqual([]); + }); + + it("after clear, loadPage(undefined) reloads from first page", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(4), + maxPageSize: { tools: 2 }, + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedToolsState(client); + await state.loadPage(); + expect(state.getTools()).toHaveLength(2); + state.clear(); + expect(state.getTools()).toEqual([]); + await state.loadPage(); + expect(state.getTools()).toHaveLength(2); + expect(state.getTools().map((t) => t.name)).toEqual(["tool_1", "tool_2"]); + }); + + it("destroy unsubscribes and clears state", async () => { + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + state = new PagedToolsState(client); + await state.loadPage(); + expect(state.getTools().length).toBeGreaterThan(0); + state.destroy(); + expect(state.getTools()).toEqual([]); + }); +}); diff --git a/core/__tests__/mcp/state/stderrLogState.test.ts b/core/__tests__/mcp/state/stderrLogState.test.ts new file mode 100644 index 000000000..23ffd0369 --- /dev/null +++ b/core/__tests__/mcp/state/stderrLogState.test.ts @@ -0,0 +1,107 @@ +/** + * StderrLogState tests use a mock protocol that dispatches "stderrLog" + * to assert the manager's list and emitted events. + */ +import { describe, it, expect, afterEach } from "vitest"; +import type { StderrLogEntry } from "../../../mcp/types.js"; +import { StderrLogState } from "../../../mcp/state/stderrLogState.js"; + +class MockStderrLogProtocol extends EventTarget { + dispatchStderrLog(entry: StderrLogEntry): void { + this.dispatchEvent(new CustomEvent("stderrLog", { detail: entry })); + } +} + +function createStderrLogEntry(message: string): StderrLogEntry { + return { + timestamp: new Date(), + message, + }; +} + +type InspectorClient = + import("../../../mcp/inspectorClient.js").InspectorClient; + +describe("StderrLogState", () => { + let protocol: MockStderrLogProtocol; + let state: StderrLogState | null = null; + + afterEach(() => { + state?.destroy(); + state = null; + }); + + it("starts with empty stderr logs", () => { + protocol = new MockStderrLogProtocol(); + state = new StderrLogState(protocol as unknown as InspectorClient); + expect(state.getStderrLogs()).toEqual([]); + }); + + it("on protocol stderrLog appends entry and dispatches stderrLog + stderrLogsChange", () => { + protocol = new MockStderrLogProtocol(); + state = new StderrLogState(protocol as unknown as InspectorClient); + const entry = createStderrLogEntry("hello"); + + const singleDetails: StderrLogEntry[] = []; + const listDetails: StderrLogEntry[][] = []; + state.addEventListener("stderrLog", (e) => singleDetails.push(e.detail)); + state.addEventListener("stderrLogsChange", (e) => + listDetails.push(e.detail), + ); + + protocol.dispatchStderrLog(entry); + + expect(state.getStderrLogs()).toHaveLength(1); + expect(state.getStderrLogs()[0]).toBe(entry); + expect(singleDetails).toHaveLength(1); + expect(singleDetails[0]).toBe(entry); + expect(listDetails).toHaveLength(1); + expect(listDetails[0]).toHaveLength(1); + }); + + it("maxStderrLogEvents option trims oldest when at capacity", () => { + protocol = new MockStderrLogProtocol(); + state = new StderrLogState(protocol as unknown as InspectorClient, { + maxStderrLogEvents: 3, + }); + protocol.dispatchStderrLog(createStderrLogEntry("a")); + protocol.dispatchStderrLog(createStderrLogEntry("b")); + protocol.dispatchStderrLog(createStderrLogEntry("c")); + expect(state.getStderrLogs()).toHaveLength(3); + protocol.dispatchStderrLog(createStderrLogEntry("d")); + expect(state.getStderrLogs()).toHaveLength(3); + expect(state.getStderrLogs().map((e) => e.message)).toEqual([ + "b", + "c", + "d", + ]); + }); + + it("clearStderrLogs() empties list and dispatches stderrLogsChange only when non-empty", () => { + protocol = new MockStderrLogProtocol(); + state = new StderrLogState(protocol as unknown as InspectorClient); + const listDetails: StderrLogEntry[][] = []; + state.addEventListener("stderrLogsChange", (e) => + listDetails.push(e.detail), + ); + state.clearStderrLogs(); + expect(listDetails).toHaveLength(0); + + protocol.dispatchStderrLog(createStderrLogEntry("x")); + expect(listDetails).toHaveLength(1); + state.clearStderrLogs(); + expect(state.getStderrLogs()).toEqual([]); + expect(listDetails).toHaveLength(2); + expect(listDetails[1]).toEqual([]); + }); + + it("destroy() unsubscribes and clears state", () => { + protocol = new MockStderrLogProtocol(); + state = new StderrLogState(protocol as unknown as InspectorClient); + protocol.dispatchStderrLog(createStderrLogEntry("x")); + state.destroy(); + expect(state.getStderrLogs()).toEqual([]); + protocol.dispatchStderrLog(createStderrLogEntry("y")); + expect(state.getStderrLogs()).toEqual([]); + }); +}); diff --git a/core/__tests__/react/useInspectorClient.test.tsx b/core/__tests__/react/useInspectorClient.test.tsx new file mode 100644 index 000000000..ee8473d8c --- /dev/null +++ b/core/__tests__/react/useInspectorClient.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useInspectorClient } from "../../react/useInspectorClient.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import { InspectorClientEventTarget } from "../../mcp/inspectorClientEventTarget.js"; +import type { ConnectionStatus } from "../../mcp/index.js"; + +/** + * Mock InspectorClient that synchronously dispatches events when connect() or + * disconnect() are called so state updates run inside act(). + */ +class MockInspectorClient extends InspectorClientEventTarget { + private status: ConnectionStatus = "disconnected"; + + getStatus(): ConnectionStatus { + return this.status; + } + getMessages() { + return []; + } + getStderrLogs() { + return []; + } + getFetchRequests() { + return []; + } + getResources() { + return []; + } + getResourceTemplates() { + return []; + } + getPrompts() { + return []; + } + getCapabilities() { + return undefined; + } + getServerInfo() { + return undefined; + } + getInstructions() { + return undefined; + } + getAppRendererClient() { + return null; + } + + async connect(): Promise { + this.status = "connected"; + this.dispatchTypedEvent("statusChange", "connected"); + } + + async disconnect(): Promise { + this.status = "disconnected"; + this.dispatchTypedEvent("statusChange", "disconnected"); + } +} + +describe("useInspectorClient", () => { + it("returns disconnected state and no-op connect/disconnect when given null", async () => { + const { result } = renderHook(() => useInspectorClient(null)); + + expect(result.current.status).toBe("disconnected"); + expect(result.current.appRendererClient).toBeNull(); + + await act(async () => { + await result.current.connect(); + }); + expect(result.current.status).toBe("disconnected"); + + await act(async () => { + await result.current.disconnect(); + }); + expect(result.current.status).toBe("disconnected"); + }); + + it("syncs initial state from InspectorClient and updates after connect", async () => { + const client = new MockInspectorClient(); + const { result } = renderHook(() => + useInspectorClient(client as unknown as InspectorClient), + ); + + expect(result.current.status).toBe("disconnected"); + + await act(async () => { + await result.current.connect(); + }); + + expect(result.current.status).toBe("connected"); + }); + + it("updates status after disconnect", async () => { + const client = new MockInspectorClient(); + const { result } = renderHook(() => + useInspectorClient(client as unknown as InspectorClient), + ); + + await act(async () => { + await result.current.connect(); + }); + expect(result.current.status).toBe("connected"); + + await act(async () => { + await result.current.disconnect(); + }); + + expect(result.current.status).toBe("disconnected"); + }); +}); diff --git a/core/__tests__/react/useManagedPrompts.test.tsx b/core/__tests__/react/useManagedPrompts.test.tsx new file mode 100644 index 000000000..be1aaa0a3 --- /dev/null +++ b/core/__tests__/react/useManagedPrompts.test.tsx @@ -0,0 +1,149 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useManagedPrompts } from "../../react/useManagedPrompts.js"; +import type { ManagedPromptsState } from "../../mcp/state/managedPromptsState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock ManagedPromptsState: getPrompts(), refresh(), and promptsChange events. + */ +class MockManagedPromptsState extends EventTarget { + private _prompts: Prompt[] = []; + + getPrompts(): Prompt[] { + return [...this._prompts]; + } + + setPrompts(prompts: Prompt[]): void { + this._prompts = prompts; + this.dispatchEvent(new CustomEvent("promptsChange", { detail: prompts })); + } + + async refresh(): Promise { + return this.getPrompts(); + } + + destroy(): void { + this._prompts = []; + } +} + +describe("useManagedPrompts", () => { + it("returns empty prompts and no-op refresh when given null client and null manager", async () => { + const { result } = renderHook(() => useManagedPrompts(null, null)); + + expect(result.current.prompts).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + expect(result.current.prompts).toEqual([]); + }); + + it("returns empty prompts when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => useManagedPrompts(client, null)); + + expect(result.current.prompts).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + }); + + it("syncs initial prompts from manager", () => { + const manager = new MockManagedPromptsState(); + manager.setPrompts([ + { name: "a", arguments: [] }, + { name: "b", arguments: [] }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedPrompts(client, manager as unknown as ManagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(2); + expect(result.current.prompts.map((p) => p.name)).toEqual(["a", "b"]); + }); + + it("updates prompts when manager dispatches promptsChange", async () => { + const manager = new MockManagedPromptsState(); + manager.setPrompts([{ name: "first", arguments: [] }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedPrompts(client, manager as unknown as ManagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(1); + expect(result.current.prompts[0]?.name).toBe("first"); + + await act(async () => { + (manager as MockManagedPromptsState).setPrompts([ + { name: "first", arguments: [] }, + { name: "second", arguments: [] }, + ]); + }); + + expect(result.current.prompts).toHaveLength(2); + expect(result.current.prompts.map((p) => p.name)).toEqual([ + "first", + "second", + ]); + }); + + it("refresh updates state from manager", async () => { + const manager = new MockManagedPromptsState(); + manager.setPrompts([{ name: "x", arguments: [] }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedPrompts(client, manager as unknown as ManagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(1); + + await act(async () => { + (manager as MockManagedPromptsState).setPrompts([ + { name: "x", arguments: [] }, + { name: "y", arguments: [] }, + ]); + }); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toHaveLength(2); + }); + + expect(result.current.prompts).toHaveLength(2); + }); + + it("clears prompts when manager switches to null", async () => { + const manager = new MockManagedPromptsState(); + manager.setPrompts([{ name: "only", arguments: [] }]); + const client = {} as InspectorClient; + + const { result, rerender } = renderHook( + ({ client: c, manager: m }) => useManagedPrompts(c, m), + { + initialProps: { + client, + manager: manager as unknown as ManagedPromptsState, + }, + }, + ); + + expect(result.current.prompts).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.prompts).toEqual([]); + }); +}); diff --git a/core/__tests__/react/useManagedRequestorTasks.test.tsx b/core/__tests__/react/useManagedRequestorTasks.test.tsx new file mode 100644 index 000000000..36ce6b253 --- /dev/null +++ b/core/__tests__/react/useManagedRequestorTasks.test.tsx @@ -0,0 +1,166 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useManagedRequestorTasks } from "../../react/useManagedRequestorTasks.js"; +import type { ManagedRequestorTasksState } from "../../mcp/state/managedRequestorTasksState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; + +function createMockTask(overrides: Partial & { taskId: string }): Task { + return { + ...overrides, + taskId: overrides.taskId, + ttl: overrides.ttl ?? null, + status: overrides.status ?? "working", + statusMessage: overrides.statusMessage ?? "", + lastUpdatedAt: overrides.lastUpdatedAt ?? new Date().toISOString(), + createdAt: overrides.createdAt ?? new Date().toISOString(), + }; +} + +class MockManagedRequestorTasksState extends EventTarget { + private _tasks: Task[] = []; + + getTasks(): Task[] { + return [...this._tasks]; + } + + async refresh(): Promise { + return this.getTasks(); + } + + setTasks(tasks: Task[]): void { + this._tasks = [...tasks]; + this.dispatchEvent(new CustomEvent("tasksChange", { detail: this._tasks })); + } + + destroy(): void { + this._tasks = []; + } +} + +describe("useManagedRequestorTasks", () => { + it("returns empty tasks and no-op refresh when given null client and null manager", async () => { + const { result } = renderHook(() => useManagedRequestorTasks(null, null)); + + expect(result.current.tasks).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + expect(result.current.tasks).toEqual([]); + }); + + it("returns empty tasks when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => useManagedRequestorTasks(client, null)); + + expect(result.current.tasks).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + }); + + it("syncs initial tasks from manager", () => { + const manager = new MockManagedRequestorTasksState(); + manager.setTasks([ + createMockTask({ taskId: "t1" }), + createMockTask({ taskId: "t2" }), + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedRequestorTasks( + client, + manager as unknown as ManagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(2); + expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1", "t2"]); + }); + + it("updates tasks when manager dispatches tasksChange", async () => { + const manager = new MockManagedRequestorTasksState(); + manager.setTasks([createMockTask({ taskId: "t1" })]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedRequestorTasks( + client, + manager as unknown as ManagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(1); + + await act(async () => { + (manager as MockManagedRequestorTasksState).setTasks([ + createMockTask({ taskId: "t1" }), + createMockTask({ taskId: "t2" }), + ]); + }); + + expect(result.current.tasks).toHaveLength(2); + }); + + it("refresh updates state from manager", async () => { + const manager = new MockManagedRequestorTasksState(); + const client = {} as InspectorClient; + (manager as MockManagedRequestorTasksState).refresh = async function () { + this.setTasks([ + createMockTask({ taskId: "a" }), + createMockTask({ taskId: "b" }), + ]); + return this.getTasks(); + }; + + const { result } = renderHook(() => + useManagedRequestorTasks( + client, + manager as unknown as ManagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(0); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toHaveLength(2); + }); + + expect(result.current.tasks).toHaveLength(2); + expect(result.current.tasks.map((t) => t.taskId)).toEqual(["a", "b"]); + }); + + it("clears tasks when manager switches to null", async () => { + const manager = new MockManagedRequestorTasksState(); + manager.setTasks([createMockTask({ taskId: "only" })]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: ManagedRequestorTasksState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => useManagedRequestorTasks(c, m), + { + initialProps: { + client, + manager: manager as unknown as ManagedRequestorTasksState, + } as Props, + }, + ); + + expect(result.current.tasks).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.tasks).toEqual([]); + }); +}); diff --git a/core/__tests__/react/useManagedResourceTemplates.test.tsx b/core/__tests__/react/useManagedResourceTemplates.test.tsx new file mode 100644 index 000000000..b415bcfff --- /dev/null +++ b/core/__tests__/react/useManagedResourceTemplates.test.tsx @@ -0,0 +1,167 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useManagedResourceTemplates } from "../../react/useManagedResourceTemplates.js"; +import type { ManagedResourceTemplatesState } from "../../mcp/state/managedResourceTemplatesState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock ManagedResourceTemplatesState: getResourceTemplates(), refresh(), and resourceTemplatesChange events. + */ +class MockManagedResourceTemplatesState extends EventTarget { + private _templates: ResourceTemplate[] = []; + + getResourceTemplates(): ResourceTemplate[] { + return [...this._templates]; + } + + setResourceTemplates(templates: ResourceTemplate[]): void { + this._templates = templates; + this.dispatchEvent( + new CustomEvent("resourceTemplatesChange", { detail: templates }), + ); + } + + async refresh(): Promise { + return this.getResourceTemplates(); + } + + destroy(): void { + this._templates = []; + } +} + +describe("useManagedResourceTemplates", () => { + it("returns empty resourceTemplates and no-op refresh when given null client and null manager", async () => { + const { result } = renderHook(() => + useManagedResourceTemplates(null, null), + ); + + expect(result.current.resourceTemplates).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + expect(result.current.resourceTemplates).toEqual([]); + }); + + it("returns empty resourceTemplates when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => + useManagedResourceTemplates(client, null), + ); + + expect(result.current.resourceTemplates).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + }); + + it("syncs initial resourceTemplates from manager", () => { + const manager = new MockManagedResourceTemplatesState(); + manager.setResourceTemplates([ + { uriTemplate: "file:///{path}", name: "file" }, + { uriTemplate: "user://{id}", name: "user" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResourceTemplates( + client, + manager as unknown as ManagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(2); + expect(result.current.resourceTemplates.map((t) => t.uriTemplate)).toEqual([ + "file:///{path}", + "user://{id}", + ]); + }); + + it("updates resourceTemplates when manager dispatches resourceTemplatesChange", async () => { + const manager = new MockManagedResourceTemplatesState(); + manager.setResourceTemplates([{ uriTemplate: "first", name: "First" }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResourceTemplates( + client, + manager as unknown as ManagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(1); + expect(result.current.resourceTemplates[0]?.uriTemplate).toBe("first"); + + await act(async () => { + (manager as MockManagedResourceTemplatesState).setResourceTemplates([ + { uriTemplate: "first", name: "First" }, + { uriTemplate: "second", name: "Second" }, + ]); + }); + + expect(result.current.resourceTemplates).toHaveLength(2); + expect(result.current.resourceTemplates.map((t) => t.uriTemplate)).toEqual([ + "first", + "second", + ]); + }); + + it("refresh updates state from manager", async () => { + const manager = new MockManagedResourceTemplatesState(); + manager.setResourceTemplates([{ uriTemplate: "x", name: "X" }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResourceTemplates( + client, + manager as unknown as ManagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(1); + + await act(async () => { + (manager as MockManagedResourceTemplatesState).setResourceTemplates([ + { uriTemplate: "x", name: "X" }, + { uriTemplate: "y", name: "Y" }, + ]); + }); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toHaveLength(2); + }); + + expect(result.current.resourceTemplates).toHaveLength(2); + }); + + it("clears resourceTemplates when manager switches to null", async () => { + const manager = new MockManagedResourceTemplatesState(); + manager.setResourceTemplates([{ uriTemplate: "only", name: "Only" }]); + const client = {} as InspectorClient; + + const { result, rerender } = renderHook( + ({ client: c, manager: m }) => useManagedResourceTemplates(c, m), + { + initialProps: { + client, + manager: manager as unknown as ManagedResourceTemplatesState, + }, + }, + ); + + expect(result.current.resourceTemplates).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.resourceTemplates).toEqual([]); + }); +}); diff --git a/core/__tests__/react/useManagedResources.test.tsx b/core/__tests__/react/useManagedResources.test.tsx new file mode 100644 index 000000000..5dfd9fed5 --- /dev/null +++ b/core/__tests__/react/useManagedResources.test.tsx @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useManagedResources } from "../../react/useManagedResources.js"; +import type { ManagedResourcesState } from "../../mcp/state/managedResourcesState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock ManagedResourcesState: getResources(), refresh(), and resourcesChange events. + */ +class MockManagedResourcesState extends EventTarget { + private _resources: Resource[] = []; + + getResources(): Resource[] { + return [...this._resources]; + } + + setResources(resources: Resource[]): void { + this._resources = resources; + this.dispatchEvent( + new CustomEvent("resourcesChange", { detail: resources }), + ); + } + + async refresh(): Promise { + return this.getResources(); + } + + destroy(): void { + this._resources = []; + } +} + +describe("useManagedResources", () => { + it("returns empty resources and no-op refresh when given null client and null manager", async () => { + const { result } = renderHook(() => useManagedResources(null, null)); + + expect(result.current.resources).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + expect(result.current.resources).toEqual([]); + }); + + it("returns empty resources when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => useManagedResources(client, null)); + + expect(result.current.resources).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + }); + + it("syncs initial resources from manager", () => { + const manager = new MockManagedResourcesState(); + manager.setResources([ + { uri: "a://1", name: "A", mimeType: "text/plain" }, + { uri: "b://2", name: "B", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResources(client, manager as unknown as ManagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(2); + expect(result.current.resources.map((r) => r.uri)).toEqual([ + "a://1", + "b://2", + ]); + }); + + it("updates resources when manager dispatches resourcesChange", async () => { + const manager = new MockManagedResourcesState(); + manager.setResources([ + { uri: "first", name: "First", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResources(client, manager as unknown as ManagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(1); + expect(result.current.resources[0]?.uri).toBe("first"); + + await act(async () => { + (manager as MockManagedResourcesState).setResources([ + { uri: "first", name: "First", mimeType: "text/plain" }, + { uri: "second", name: "Second", mimeType: "text/plain" }, + ]); + }); + + expect(result.current.resources).toHaveLength(2); + expect(result.current.resources.map((r) => r.uri)).toEqual([ + "first", + "second", + ]); + }); + + it("refresh updates state from manager", async () => { + const manager = new MockManagedResourcesState(); + manager.setResources([{ uri: "x", name: "X", mimeType: "text/plain" }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedResources(client, manager as unknown as ManagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(1); + + await act(async () => { + (manager as MockManagedResourcesState).setResources([ + { uri: "x", name: "X", mimeType: "text/plain" }, + { uri: "y", name: "Y", mimeType: "text/plain" }, + ]); + }); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toHaveLength(2); + }); + + expect(result.current.resources).toHaveLength(2); + }); + + it("clears resources when manager switches to null", async () => { + const manager = new MockManagedResourcesState(); + manager.setResources([ + { uri: "only", name: "Only", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result, rerender } = renderHook( + ({ client: c, manager: m }) => useManagedResources(c, m), + { + initialProps: { + client, + manager: manager as unknown as ManagedResourcesState, + }, + }, + ); + + expect(result.current.resources).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.resources).toEqual([]); + }); +}); diff --git a/core/__tests__/react/useManagedTools.test.tsx b/core/__tests__/react/useManagedTools.test.tsx new file mode 100644 index 000000000..c4243423b --- /dev/null +++ b/core/__tests__/react/useManagedTools.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useManagedTools } from "../../react/useManagedTools.js"; +import type { ManagedToolsState } from "../../mcp/state/managedToolsState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock ManagedToolsState: getTools(), refresh(), and toolsChange events. + */ +class MockManagedToolsState extends EventTarget { + private _tools: Tool[] = []; + + getTools(): Tool[] { + return [...this._tools]; + } + + setTools(tools: Tool[]): void { + this._tools = tools; + this.dispatchEvent(new CustomEvent("toolsChange", { detail: tools })); + } + + async refresh(): Promise { + return this.getTools(); + } + + destroy(): void { + this._tools = []; + } +} + +describe("useManagedTools", () => { + it("returns empty tools and no-op refresh when given null client and null manager", async () => { + const { result } = renderHook(() => useManagedTools(null, null)); + + expect(result.current.tools).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + expect(result.current.tools).toEqual([]); + }); + + it("returns empty tools when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => useManagedTools(client, null)); + + expect(result.current.tools).toEqual([]); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toEqual([]); + }); + }); + + it("syncs initial tools from manager", () => { + const manager = new MockManagedToolsState(); + manager.setTools([ + { name: "a", inputSchema: { type: "object" as const } }, + { name: "b", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedTools(client, manager as unknown as ManagedToolsState), + ); + + expect(result.current.tools).toHaveLength(2); + expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]); + }); + + it("updates tools when manager dispatches toolsChange", async () => { + const manager = new MockManagedToolsState(); + manager.setTools([ + { name: "first", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedTools(client, manager as unknown as ManagedToolsState), + ); + + expect(result.current.tools).toHaveLength(1); + expect(result.current.tools[0]?.name).toBe("first"); + + await act(async () => { + (manager as MockManagedToolsState).setTools([ + { name: "first", inputSchema: { type: "object" as const } }, + { name: "second", inputSchema: { type: "object" as const } }, + ]); + }); + + expect(result.current.tools).toHaveLength(2); + expect(result.current.tools.map((t) => t.name)).toEqual([ + "first", + "second", + ]); + }); + + it("refresh updates state from manager", async () => { + const manager = new MockManagedToolsState(); + manager.setTools([{ name: "x", inputSchema: { type: "object" as const } }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + useManagedTools(client, manager as unknown as ManagedToolsState), + ); + + expect(result.current.tools).toHaveLength(1); + + await act(async () => { + (manager as MockManagedToolsState).setTools([ + { name: "x", inputSchema: { type: "object" as const } }, + { name: "y", inputSchema: { type: "object" as const } }, + ]); + }); + + await act(async () => { + const next = await result.current.refresh(); + expect(next).toHaveLength(2); + }); + + expect(result.current.tools).toHaveLength(2); + }); + + it("clears tools when manager switches to null", async () => { + const manager = new MockManagedToolsState(); + manager.setTools([ + { name: "only", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result, rerender } = renderHook( + ({ client: c, manager: m }) => useManagedTools(c, m), + { + initialProps: { + client, + manager: manager as unknown as ManagedToolsState, + }, + }, + ); + + expect(result.current.tools).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.tools).toEqual([]); + }); +}); diff --git a/core/__tests__/react/usePagedPrompts.test.tsx b/core/__tests__/react/usePagedPrompts.test.tsx new file mode 100644 index 000000000..246107942 --- /dev/null +++ b/core/__tests__/react/usePagedPrompts.test.tsx @@ -0,0 +1,206 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePagedPrompts } from "../../react/usePagedPrompts.js"; +import type { PagedPromptsState } from "../../mcp/state/pagedPromptsState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock PagedPromptsState: getPrompts(), loadPage(), and promptsChange events. + */ +class MockPagedPromptsState extends EventTarget { + private _prompts: Prompt[] = []; + + getPrompts(): Prompt[] { + return [...this._prompts]; + } + + appendPrompts(prompts: Prompt[]): void { + this._prompts = [...this._prompts, ...prompts]; + this.dispatchEvent( + new CustomEvent("promptsChange", { detail: this._prompts }), + ); + } + + async loadPage(_cursor?: string): Promise<{ + prompts: Prompt[]; + nextCursor?: string; + }> { + return { prompts: this._prompts, nextCursor: undefined }; + } + + simulateLoadPage(prompts: Prompt[], _nextCursor?: string): void { + this._prompts = [...this._prompts, ...prompts]; + this.dispatchEvent( + new CustomEvent("promptsChange", { detail: this._prompts }), + ); + } + + clear(): void { + this._prompts = []; + this.dispatchEvent( + new CustomEvent("promptsChange", { detail: this._prompts }), + ); + } + + destroy(): void { + this._prompts = []; + } +} + +describe("usePagedPrompts", () => { + it("returns empty prompts, no-op loadPage, and no-op clear when given null client and null manager", async () => { + const { result } = renderHook(() => usePagedPrompts(null, null)); + + expect(result.current.prompts).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.prompts).toEqual([]); + expect(page.nextCursor).toBeUndefined(); + }); + expect(result.current.prompts).toEqual([]); + + act(() => { + result.current.clear(); + }); + expect(result.current.prompts).toEqual([]); + }); + + it("returns empty prompts when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => usePagedPrompts(client, null)); + + expect(result.current.prompts).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.prompts).toEqual([]); + }); + }); + + it("syncs initial prompts from manager", () => { + const manager = new MockPagedPromptsState(); + manager.appendPrompts([ + { name: "a", arguments: [] }, + { name: "b", arguments: [] }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedPrompts(client, manager as unknown as PagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(2); + expect(result.current.prompts.map((p) => p.name)).toEqual(["a", "b"]); + }); + + it("updates prompts when manager dispatches promptsChange", async () => { + const manager = new MockPagedPromptsState(); + manager.appendPrompts([{ name: "first", arguments: [] }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedPrompts(client, manager as unknown as PagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(1); + expect(result.current.prompts[0]?.name).toBe("first"); + + await act(async () => { + (manager as MockPagedPromptsState).simulateLoadPage([ + { name: "second", arguments: [] }, + ]); + }); + + expect(result.current.prompts).toHaveLength(2); + expect(result.current.prompts.map((p) => p.name)).toEqual([ + "first", + "second", + ]); + }); + + it("loadPage updates state from manager", async () => { + const manager = new MockPagedPromptsState(); + const client = {} as InspectorClient; + (manager as MockPagedPromptsState).loadPage = async function ( + _cursor?: string, + ) { + this.simulateLoadPage([ + { name: "x", arguments: [] }, + { name: "y", arguments: [] }, + ]); + return { + prompts: [ + { name: "x", arguments: [] }, + { name: "y", arguments: [] }, + ], + nextCursor: undefined, + }; + }; + + const { result } = renderHook(() => + usePagedPrompts(client, manager as unknown as PagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(0); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.prompts).toHaveLength(2); + }); + + expect(result.current.prompts).toHaveLength(2); + expect(result.current.prompts.map((p) => p.name)).toEqual(["x", "y"]); + }); + + it("clear empties prompts", async () => { + const manager = new MockPagedPromptsState(); + manager.appendPrompts([ + { name: "a", arguments: [] }, + { name: "b", arguments: [] }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedPrompts(client, manager as unknown as PagedPromptsState), + ); + + expect(result.current.prompts).toHaveLength(2); + + act(() => { + result.current.clear(); + }); + + expect(result.current.prompts).toEqual([]); + }); + + it("clears prompts when manager switches to null", async () => { + const manager = new MockPagedPromptsState(); + manager.appendPrompts([{ name: "only", arguments: [] }]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: PagedPromptsState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => usePagedPrompts(c, m), + { + initialProps: { + client, + manager: manager as unknown as PagedPromptsState, + } as Props, + }, + ); + + expect(result.current.prompts).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.prompts).toEqual([]); + }); +}); diff --git a/core/__tests__/react/usePagedRequestorTasks.test.tsx b/core/__tests__/react/usePagedRequestorTasks.test.tsx new file mode 100644 index 000000000..652818eb9 --- /dev/null +++ b/core/__tests__/react/usePagedRequestorTasks.test.tsx @@ -0,0 +1,232 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePagedRequestorTasks } from "../../react/usePagedRequestorTasks.js"; +import type { PagedRequestorTasksState } from "../../mcp/state/pagedRequestorTasksState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; + +function createMockTask(overrides: Partial & { taskId: string }): Task { + return { + ...overrides, + taskId: overrides.taskId, + ttl: overrides.ttl ?? null, + status: overrides.status ?? "working", + statusMessage: overrides.statusMessage ?? "", + lastUpdatedAt: overrides.lastUpdatedAt ?? new Date().toISOString(), + createdAt: overrides.createdAt ?? new Date().toISOString(), + }; +} + +class MockPagedRequestorTasksState extends EventTarget { + private _tasks: Task[] = []; + private _nextCursor: string | undefined = undefined; + + getTasks(): Task[] { + return [...this._tasks]; + } + + getNextCursor(): string | undefined { + return this._nextCursor; + } + + appendTasks(tasks: Task[], nextCursor?: string): void { + this._tasks = [...this._tasks, ...tasks]; + this._nextCursor = nextCursor; + this.dispatchEvent(new CustomEvent("tasksChange", { detail: this._tasks })); + } + + async loadPage( + _cursor?: string, + ): Promise<{ tasks: Task[]; nextCursor?: string }> { + return { tasks: this._tasks, nextCursor: this._nextCursor }; + } + + simulateLoadPage(tasks: Task[], nextCursor?: string): void { + this._tasks = [...this._tasks, ...tasks]; + this._nextCursor = nextCursor; + this.dispatchEvent(new CustomEvent("tasksChange", { detail: this._tasks })); + } + + clear(): void { + this._tasks = []; + this._nextCursor = undefined; + this.dispatchEvent(new CustomEvent("tasksChange", { detail: this._tasks })); + } + + destroy(): void { + this._tasks = []; + this._nextCursor = undefined; + } +} + +describe("usePagedRequestorTasks", () => { + it("returns empty tasks, no-op loadPage, no-op clear, and undefined nextCursor when given null client and null manager", async () => { + const { result } = renderHook(() => usePagedRequestorTasks(null, null)); + + expect(result.current.tasks).toEqual([]); + expect(result.current.nextCursor).toBeUndefined(); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tasks).toEqual([]); + expect(page.nextCursor).toBeUndefined(); + }); + expect(result.current.tasks).toEqual([]); + + act(() => { + result.current.clear(); + }); + expect(result.current.tasks).toEqual([]); + }); + + it("returns empty tasks when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => usePagedRequestorTasks(client, null)); + + expect(result.current.tasks).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tasks).toEqual([]); + }); + }); + + it("syncs initial tasks and nextCursor from manager", () => { + const manager = new MockPagedRequestorTasksState(); + manager.appendTasks( + [createMockTask({ taskId: "t1" }), createMockTask({ taskId: "t2" })], + "cursor-next", + ); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedRequestorTasks( + client, + manager as unknown as PagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(2); + expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1", "t2"]); + expect(result.current.nextCursor).toBe("cursor-next"); + }); + + it("updates tasks when manager dispatches tasksChange", async () => { + const manager = new MockPagedRequestorTasksState(); + manager.appendTasks([createMockTask({ taskId: "t1" })]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedRequestorTasks( + client, + manager as unknown as PagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(1); + + await act(async () => { + (manager as MockPagedRequestorTasksState).appendTasks([ + createMockTask({ taskId: "t2" }), + ]); + }); + + expect(result.current.tasks).toHaveLength(2); + expect(result.current.tasks.map((t) => t.taskId)).toEqual(["t1", "t2"]); + }); + + it("loadPage updates state from manager", async () => { + const manager = new MockPagedRequestorTasksState(); + const client = {} as InspectorClient; + (manager as MockPagedRequestorTasksState).loadPage = async function ( + _cursor?: string, + ) { + (this as unknown as { _tasks: Task[] })._tasks = [ + createMockTask({ taskId: "x" }), + createMockTask({ taskId: "y" }), + ]; + (this as unknown as { _nextCursor: string | undefined })._nextCursor = + undefined; + this.dispatchEvent( + new CustomEvent("tasksChange", { + detail: (this as unknown as { _tasks: Task[] })._tasks, + }), + ); + return { + tasks: (this as unknown as { _tasks: Task[] })._tasks, + nextCursor: undefined, + }; + }; + + const { result } = renderHook(() => + usePagedRequestorTasks( + client, + manager as unknown as PagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(0); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tasks).toHaveLength(2); + }); + + expect(result.current.tasks).toHaveLength(2); + expect(result.current.tasks.map((t) => t.taskId)).toEqual(["x", "y"]); + }); + + it("clear empties tasks", async () => { + const manager = new MockPagedRequestorTasksState(); + manager.appendTasks([ + createMockTask({ taskId: "t1" }), + createMockTask({ taskId: "t2" }), + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedRequestorTasks( + client, + manager as unknown as PagedRequestorTasksState, + ), + ); + + expect(result.current.tasks).toHaveLength(2); + + act(() => { + result.current.clear(); + }); + + expect(result.current.tasks).toEqual([]); + }); + + it("clears tasks when manager switches to null", async () => { + const manager = new MockPagedRequestorTasksState(); + manager.appendTasks([createMockTask({ taskId: "only" })]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: PagedRequestorTasksState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => usePagedRequestorTasks(c, m), + { + initialProps: { + client, + manager: manager as unknown as PagedRequestorTasksState, + } as Props, + }, + ); + + expect(result.current.tasks).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.tasks).toEqual([]); + expect(result.current.nextCursor).toBeUndefined(); + }); +}); diff --git a/core/__tests__/react/usePagedResourceTemplates.test.tsx b/core/__tests__/react/usePagedResourceTemplates.test.tsx new file mode 100644 index 000000000..5f4671b11 --- /dev/null +++ b/core/__tests__/react/usePagedResourceTemplates.test.tsx @@ -0,0 +1,233 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePagedResourceTemplates } from "../../react/usePagedResourceTemplates.js"; +import type { PagedResourceTemplatesState } from "../../mcp/state/pagedResourceTemplatesState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock PagedResourceTemplatesState: getResourceTemplates(), loadPage(), and resourceTemplatesChange events. + */ +class MockPagedResourceTemplatesState extends EventTarget { + private _templates: ResourceTemplate[] = []; + + getResourceTemplates(): ResourceTemplate[] { + return [...this._templates]; + } + + appendTemplates(templates: ResourceTemplate[]): void { + this._templates = [...this._templates, ...templates]; + this.dispatchEvent( + new CustomEvent("resourceTemplatesChange", { + detail: this._templates, + }), + ); + } + + async loadPage(_cursor?: string): Promise<{ + resourceTemplates: ResourceTemplate[]; + nextCursor?: string; + }> { + return { + resourceTemplates: this._templates, + nextCursor: undefined, + }; + } + + simulateLoadPage(templates: ResourceTemplate[], _nextCursor?: string): void { + this._templates = [...this._templates, ...templates]; + this.dispatchEvent( + new CustomEvent("resourceTemplatesChange", { + detail: this._templates, + }), + ); + } + + clear(): void { + this._templates = []; + this.dispatchEvent( + new CustomEvent("resourceTemplatesChange", { detail: this._templates }), + ); + } + + destroy(): void { + this._templates = []; + } +} + +describe("usePagedResourceTemplates", () => { + it("returns empty resourceTemplates, no-op loadPage, and no-op clear when given null client and null manager", async () => { + const { result } = renderHook(() => usePagedResourceTemplates(null, null)); + + expect(result.current.resourceTemplates).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resourceTemplates).toEqual([]); + expect(page.nextCursor).toBeUndefined(); + }); + expect(result.current.resourceTemplates).toEqual([]); + + act(() => { + result.current.clear(); + }); + expect(result.current.resourceTemplates).toEqual([]); + }); + + it("returns empty resourceTemplates when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => + usePagedResourceTemplates(client, null), + ); + + expect(result.current.resourceTemplates).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resourceTemplates).toEqual([]); + }); + }); + + it("syncs initial resourceTemplates from manager", () => { + const manager = new MockPagedResourceTemplatesState(); + manager.appendTemplates([ + { uriTemplate: "file:///{path}", name: "file" }, + { uriTemplate: "user://{id}", name: "user" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResourceTemplates( + client, + manager as unknown as PagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(2); + expect(result.current.resourceTemplates.map((t) => t.uriTemplate)).toEqual([ + "file:///{path}", + "user://{id}", + ]); + }); + + it("updates resourceTemplates when manager dispatches resourceTemplatesChange", async () => { + const manager = new MockPagedResourceTemplatesState(); + manager.appendTemplates([{ uriTemplate: "first", name: "First" }]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResourceTemplates( + client, + manager as unknown as PagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(1); + expect(result.current.resourceTemplates[0]?.uriTemplate).toBe("first"); + + await act(async () => { + (manager as MockPagedResourceTemplatesState).simulateLoadPage([ + { uriTemplate: "second", name: "Second" }, + ]); + }); + + expect(result.current.resourceTemplates).toHaveLength(2); + expect(result.current.resourceTemplates.map((t) => t.uriTemplate)).toEqual([ + "first", + "second", + ]); + }); + + it("loadPage updates state from manager", async () => { + const manager = new MockPagedResourceTemplatesState(); + const client = {} as InspectorClient; + (manager as MockPagedResourceTemplatesState).loadPage = async function ( + _cursor?: string, + ) { + this.simulateLoadPage([ + { uriTemplate: "x", name: "X" }, + { uriTemplate: "y", name: "Y" }, + ]); + return { + resourceTemplates: [ + { uriTemplate: "x", name: "X" }, + { uriTemplate: "y", name: "Y" }, + ], + nextCursor: undefined, + }; + }; + + const { result } = renderHook(() => + usePagedResourceTemplates( + client, + manager as unknown as PagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(0); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resourceTemplates).toHaveLength(2); + }); + + expect(result.current.resourceTemplates).toHaveLength(2); + expect(result.current.resourceTemplates.map((t) => t.uriTemplate)).toEqual([ + "x", + "y", + ]); + }); + + it("clear empties resourceTemplates", async () => { + const manager = new MockPagedResourceTemplatesState(); + manager.appendTemplates([ + { uriTemplate: "a", name: "A" }, + { uriTemplate: "b", name: "B" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResourceTemplates( + client, + manager as unknown as PagedResourceTemplatesState, + ), + ); + + expect(result.current.resourceTemplates).toHaveLength(2); + + act(() => { + result.current.clear(); + }); + + expect(result.current.resourceTemplates).toEqual([]); + }); + + it("clears resourceTemplates when manager switches to null", async () => { + const manager = new MockPagedResourceTemplatesState(); + manager.appendTemplates([{ uriTemplate: "only", name: "Only" }]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: PagedResourceTemplatesState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => usePagedResourceTemplates(c, m), + { + initialProps: { + client, + manager: manager as unknown as PagedResourceTemplatesState, + } as Props, + }, + ); + + expect(result.current.resourceTemplates).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.resourceTemplates).toEqual([]); + }); +}); diff --git a/core/__tests__/react/usePagedResources.test.tsx b/core/__tests__/react/usePagedResources.test.tsx new file mode 100644 index 000000000..d68d933df --- /dev/null +++ b/core/__tests__/react/usePagedResources.test.tsx @@ -0,0 +1,210 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePagedResources } from "../../react/usePagedResources.js"; +import type { PagedResourcesState } from "../../mcp/state/pagedResourcesState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock PagedResourcesState: getResources(), loadPage(), and resourcesChange events. + */ +class MockPagedResourcesState extends EventTarget { + private _resources: Resource[] = []; + + getResources(): Resource[] { + return [...this._resources]; + } + + appendResources(resources: Resource[]): void { + this._resources = [...this._resources, ...resources]; + this.dispatchEvent( + new CustomEvent("resourcesChange", { detail: this._resources }), + ); + } + + async loadPage(_cursor?: string): Promise<{ + resources: Resource[]; + nextCursor?: string; + }> { + return { resources: this._resources, nextCursor: undefined }; + } + + simulateLoadPage(resources: Resource[], _nextCursor?: string): void { + this._resources = [...this._resources, ...resources]; + this.dispatchEvent( + new CustomEvent("resourcesChange", { detail: this._resources }), + ); + } + + clear(): void { + this._resources = []; + this.dispatchEvent( + new CustomEvent("resourcesChange", { detail: this._resources }), + ); + } + + destroy(): void { + this._resources = []; + } +} + +describe("usePagedResources", () => { + it("returns empty resources, no-op loadPage, and no-op clear when given null client and null manager", async () => { + const { result } = renderHook(() => usePagedResources(null, null)); + + expect(result.current.resources).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resources).toEqual([]); + expect(page.nextCursor).toBeUndefined(); + }); + expect(result.current.resources).toEqual([]); + + act(() => { + result.current.clear(); + }); + expect(result.current.resources).toEqual([]); + }); + + it("returns empty resources when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => usePagedResources(client, null)); + + expect(result.current.resources).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resources).toEqual([]); + }); + }); + + it("syncs initial resources from manager", () => { + const manager = new MockPagedResourcesState(); + manager.appendResources([ + { uri: "a", name: "A", mimeType: "text/plain" }, + { uri: "b", name: "B", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResources(client, manager as unknown as PagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(2); + expect(result.current.resources.map((r) => r.uri)).toEqual(["a", "b"]); + }); + + it("updates resources when manager dispatches resourcesChange", async () => { + const manager = new MockPagedResourcesState(); + manager.appendResources([ + { uri: "first", name: "First", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResources(client, manager as unknown as PagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(1); + expect(result.current.resources[0]?.uri).toBe("first"); + + await act(async () => { + (manager as MockPagedResourcesState).simulateLoadPage([ + { uri: "second", name: "Second", mimeType: "text/plain" }, + ]); + }); + + expect(result.current.resources).toHaveLength(2); + expect(result.current.resources.map((r) => r.uri)).toEqual([ + "first", + "second", + ]); + }); + + it("loadPage updates state from manager", async () => { + const manager = new MockPagedResourcesState(); + const client = {} as InspectorClient; + (manager as MockPagedResourcesState).loadPage = async function ( + _cursor?: string, + ) { + this.simulateLoadPage([ + { uri: "x", name: "X", mimeType: "text/plain" }, + { uri: "y", name: "Y", mimeType: "text/plain" }, + ]); + return { + resources: [ + { uri: "x", name: "X", mimeType: "text/plain" }, + { uri: "y", name: "Y", mimeType: "text/plain" }, + ], + nextCursor: undefined, + }; + }; + + const { result } = renderHook(() => + usePagedResources(client, manager as unknown as PagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(0); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.resources).toHaveLength(2); + }); + + expect(result.current.resources).toHaveLength(2); + expect(result.current.resources.map((r) => r.uri)).toEqual(["x", "y"]); + }); + + it("clear empties resources", async () => { + const manager = new MockPagedResourcesState(); + manager.appendResources([ + { uri: "a", name: "A", mimeType: "text/plain" }, + { uri: "b", name: "B", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedResources(client, manager as unknown as PagedResourcesState), + ); + + expect(result.current.resources).toHaveLength(2); + + act(() => { + result.current.clear(); + }); + + expect(result.current.resources).toEqual([]); + }); + + it("clears resources when manager switches to null", async () => { + const manager = new MockPagedResourcesState(); + manager.appendResources([ + { uri: "only", name: "Only", mimeType: "text/plain" }, + ]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: PagedResourcesState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => usePagedResources(c, m), + { + initialProps: { + client, + manager: manager as unknown as PagedResourcesState, + } as Props, + }, + ); + + expect(result.current.resources).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.resources).toEqual([]); + }); +}); diff --git a/core/__tests__/react/usePagedTools.test.tsx b/core/__tests__/react/usePagedTools.test.tsx new file mode 100644 index 000000000..92acb8822 --- /dev/null +++ b/core/__tests__/react/usePagedTools.test.tsx @@ -0,0 +1,206 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { usePagedTools } from "../../react/usePagedTools.js"; +import type { PagedToolsState } from "../../mcp/state/pagedToolsState.js"; +import type { InspectorClient } from "../../mcp/inspectorClient.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Mock PagedToolsState: getTools(), loadPage(), and toolsChange events. + */ +class MockPagedToolsState extends EventTarget { + private _tools: Tool[] = []; + + getTools(): Tool[] { + return [...this._tools]; + } + + appendTools(tools: Tool[]): void { + this._tools = [...this._tools, ...tools]; + this.dispatchEvent(new CustomEvent("toolsChange", { detail: this._tools })); + } + + async loadPage(_cursor?: string): Promise<{ + tools: Tool[]; + nextCursor?: string; + }> { + return { tools: this._tools, nextCursor: undefined }; + } + + /** Test helper: simulate loading a page that adds tools. */ + simulateLoadPage(tools: Tool[], _nextCursor?: string): void { + this._tools = [...this._tools, ...tools]; + this.dispatchEvent(new CustomEvent("toolsChange", { detail: this._tools })); + } + + clear(): void { + this._tools = []; + this.dispatchEvent(new CustomEvent("toolsChange", { detail: this._tools })); + } + + destroy(): void { + this._tools = []; + } +} + +describe("usePagedTools", () => { + it("returns empty tools, no-op loadPage, and no-op clear when given null client and null manager", async () => { + const { result } = renderHook(() => usePagedTools(null, null)); + + expect(result.current.tools).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tools).toEqual([]); + expect(page.nextCursor).toBeUndefined(); + }); + expect(result.current.tools).toEqual([]); + + act(() => { + result.current.clear(); + }); + expect(result.current.tools).toEqual([]); + }); + + it("returns empty tools when manager is null", async () => { + const client = {} as InspectorClient; + const { result } = renderHook(() => usePagedTools(client, null)); + + expect(result.current.tools).toEqual([]); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tools).toEqual([]); + }); + }); + + it("syncs initial tools from manager", () => { + const manager = new MockPagedToolsState(); + manager.appendTools([ + { name: "a", inputSchema: { type: "object" as const } }, + { name: "b", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedTools(client, manager as unknown as PagedToolsState), + ); + + expect(result.current.tools).toHaveLength(2); + expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]); + }); + + it("updates tools when manager dispatches toolsChange", async () => { + const manager = new MockPagedToolsState(); + manager.appendTools([ + { name: "first", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedTools(client, manager as unknown as PagedToolsState), + ); + + expect(result.current.tools).toHaveLength(1); + expect(result.current.tools[0]?.name).toBe("first"); + + await act(async () => { + (manager as MockPagedToolsState).simulateLoadPage([ + { name: "second", inputSchema: { type: "object" as const } }, + ]); + }); + + expect(result.current.tools).toHaveLength(2); + expect(result.current.tools.map((t) => t.name)).toEqual([ + "first", + "second", + ]); + }); + + it("loadPage updates state from manager", async () => { + const manager = new MockPagedToolsState(); + const client = {} as InspectorClient; + // Mock loadPage to add tools when called + (manager as MockPagedToolsState).loadPage = async function ( + _cursor?: string, + ) { + this.simulateLoadPage([ + { name: "x", inputSchema: { type: "object" as const } }, + { name: "y", inputSchema: { type: "object" as const } }, + ]); + return { + tools: [ + { name: "x", inputSchema: { type: "object" as const } }, + { name: "y", inputSchema: { type: "object" as const } }, + ], + nextCursor: undefined, + }; + }; + + const { result } = renderHook(() => + usePagedTools(client, manager as unknown as PagedToolsState), + ); + + expect(result.current.tools).toHaveLength(0); + + await act(async () => { + const page = await result.current.loadPage(); + expect(page.tools).toHaveLength(2); + }); + + expect(result.current.tools).toHaveLength(2); + expect(result.current.tools.map((t) => t.name)).toEqual(["x", "y"]); + }); + + it("clear empties tools", async () => { + const manager = new MockPagedToolsState(); + manager.appendTools([ + { name: "a", inputSchema: { type: "object" as const } }, + { name: "b", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + const { result } = renderHook(() => + usePagedTools(client, manager as unknown as PagedToolsState), + ); + + expect(result.current.tools).toHaveLength(2); + + act(() => { + result.current.clear(); + }); + + expect(result.current.tools).toEqual([]); + }); + + it("clears tools when manager switches to null", async () => { + const manager = new MockPagedToolsState(); + manager.appendTools([ + { name: "only", inputSchema: { type: "object" as const } }, + ]); + const client = {} as InspectorClient; + + type Props = { + client: InspectorClient | null; + manager: PagedToolsState | null; + }; + const { result, rerender } = renderHook( + ({ client: c, manager: m }: Props) => usePagedTools(c, m), + { + initialProps: { + client, + manager: manager as unknown as PagedToolsState, + } as Props, + }, + ); + + expect(result.current.tools).toHaveLength(1); + + rerender({ client, manager: null }); + + expect(result.current.tools).toEqual([]); + }); +}); diff --git a/core/__tests__/remote-server-config.test.ts b/core/__tests__/remote-server-config.test.ts new file mode 100644 index 000000000..0a1b333a4 --- /dev/null +++ b/core/__tests__/remote-server-config.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { createRemoteApp } from "../mcp/remote/node/server.js"; + +describe("createRemoteApp GET /api/config", () => { + it("includes sandboxUrl in response when option is set", async () => { + const sandboxUrl = "http://localhost:9123/sandbox"; + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + allowedOrigins: ["http://127.0.0.1:6274"], + sandboxUrl, + }); + const res = await app.request(new Request("http://test/api/config")); + expect(res.status).toBe(200); + const data = (await res.json()) as { sandboxUrl?: string }; + expect(data.sandboxUrl).toBe(sandboxUrl); + }); + + it("omits sandboxUrl when option is not set", async () => { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + allowedOrigins: ["http://127.0.0.1:6274"], + }); + const res = await app.request(new Request("http://test/api/config")); + expect(res.status).toBe(200); + const data = (await res.json()) as { sandboxUrl?: string }; + expect(data).not.toHaveProperty("sandboxUrl"); + }); +}); diff --git a/core/__tests__/remote-transport.test.ts b/core/__tests__/remote-transport.test.ts new file mode 100644 index 000000000..5ca10b4a5 --- /dev/null +++ b/core/__tests__/remote-transport.test.ts @@ -0,0 +1,1002 @@ +/** + * E2E tests for remote transport (stdio, SSE, streamable-http). + * Verifies connection, tools, fetch tracking, stderr logging, and remote logging over the remote. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import pino from "pino"; +import { InspectorClient } from "../mcp/inspectorClient.js"; +import { FetchRequestLogState, StderrLogState } from "../mcp/state/index.js"; +import { createRemoteTransport } from "../mcp/remote/createRemoteTransport.js"; +import { createRemoteLogger } from "../mcp/remote/createRemoteLogger.js"; +import { createRemoteApp } from "../mcp/remote/node/server.js"; +import { + createTestServerHttp, + getTestMcpServerCommand, + createEchoTool, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import type { MCPServerConfig } from "../mcp/types.js"; + +interface StartRemoteServerOptions { + logger?: pino.Logger; + storageDir?: string; + allowedOrigins?: string[]; + /** When true, API routes do not require x-mcp-remote-auth (token is still returned as empty string) */ + dangerouslyOmitAuth?: boolean; +} + +async function startRemoteServer( + port: number, + options: StartRemoteServerOptions = {}, +): Promise<{ + baseUrl: string; + server: ServerType; + authToken: string; +}> { + const { app, authToken } = createRemoteApp({ + logger: options.logger, + storageDir: options.storageDir, + allowedOrigins: options.allowedOrigins, + dangerouslyOmitAuth: options.dangerouslyOmitAuth, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port, hostname: "127.0.0.1" }, + (info) => { + const actualPort = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : port; + resolve({ + baseUrl: `http://127.0.0.1:${actualPort}`, + server, + authToken, + }); + }, + ); + server.on("error", reject); + }); +} + +describe("Remote transport e2e", () => { + let remoteServer: ServerType | null; + let mcpHttpServer: Awaited> | null; + + beforeEach(() => { + remoteServer = null; + mcpHttpServer = null; + }); + + afterEach(async () => { + if (remoteServer) { + await new Promise((resolve, reject) => { + remoteServer!.close((err) => (err ? reject(err) : resolve())); + }); + remoteServer = null; + } + if (mcpHttpServer) { + try { + await mcpHttpServer.stop(); + } catch { + // Ignore stop errors + } + mcpHttpServer = null; + } + }); + + async function setupRemoteAndConnect(config: MCPServerConfig): Promise<{ + client: InspectorClient; + fetchRequestLogState: FetchRequestLogState; + stderrLogState: StderrLogState; + }> { + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const createTransport = createRemoteTransport({ baseUrl, authToken }); + const client = new InspectorClient(config, { + environment: { + transport: createTransport, + }, + pipeStderr: true, + }); + const fetchRequestLogState = new FetchRequestLogState(client); + const stderrLogState = new StderrLogState(client); + + await client.connect(); + + return { client, fetchRequestLogState, stderrLogState }; + } + + it("smoke: remote server accepts connect and returns sessionId for SSE", async () => { + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + await mcpHttpServer.start(); + + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: mcpHttpServer!.url }, + }), + }); + + const json = (await res.json()) as { sessionId?: string; error?: string }; + if (!res.ok) { + throw new Error( + `Connect failed: ${res.status} ${json.error ?? (await res.text())}`, + ); + } + expect(json.sessionId).toBeDefined(); + expect(typeof json.sessionId).toBe("string"); + }); + + describe("stdio", () => { + it("connects, lists tools, and forwards stderr over remote", async () => { + const serverCommand = getTestMcpServerCommand(); + const config: MCPServerConfig = { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }; + + const { client, stderrLogState } = await setupRemoteAndConnect(config); + + try { + expect(client.getStatus()).toBe("connected"); + + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); + expect(tools.tools.some((t) => t.name === "echo")).toBe(true); + + const stderrLogs = stderrLogState.getStderrLogs(); + expect(Array.isArray(stderrLogs)).toBe(true); + } finally { + await client.disconnect(); + } + }); + + it("validates stderr content over remote stdio", async () => { + const serverCommand = getTestMcpServerCommand(); + const config: MCPServerConfig = { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }; + + const { client, stderrLogState } = await setupRemoteAndConnect(config); + + try { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "write_to_stderr"); + expect(tool).toBeDefined(); + const testMessage = `stderr-remote-${Date.now()}`; + await client.callTool(tool!, { message: testMessage }); + + const stderrLogs = stderrLogState.getStderrLogs(); + expect(Array.isArray(stderrLogs)).toBe(true); + const matching = stderrLogs.filter((l) => + l.message.includes(testMessage), + ); + expect(matching.length).toBeGreaterThan(0); + expect(matching[0]!.message).toContain(testMessage); + } finally { + await client.disconnect(); + } + }); + + it("calls a tool over remote stdio", async () => { + const serverCommand = getTestMcpServerCommand(); + const config: MCPServerConfig = { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }; + + const { client } = await setupRemoteAndConnect(config); + + try { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "echo"); + expect(tool).toBeDefined(); + const invocation = await client.callTool(tool!, { + message: "hello-remote", + }); + expect(invocation.result?.content).toBeDefined(); + const textContent = invocation.result?.content?.find( + (c: { type: string }) => c.type === "text", + ); + expect(textContent).toBeDefined(); + expect((textContent as { type: "text"; text: string }).text).toContain( + "Echo: hello-remote", + ); + } finally { + await client.disconnect(); + } + }); + }); + + describe("SSE", () => { + it("connects, lists tools, and receives fetch_request events over remote", async () => { + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "sse", + url: mcpHttpServer.url, + }; + + const { client, fetchRequestLogState } = + await setupRemoteAndConnect(config); + + try { + expect(client.getStatus()).toBe("connected"); + + await client.listTools(); + + // Fetch tracking: remote server applies createFetchTracker when creating + // the transport; it emits fetch_request events over SSE to the client. + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + const getRequest = fetchRequests.find((r) => r.method === "GET"); + expect(getRequest).toBeDefined(); + if (getRequest) { + expect(getRequest.url).toContain("/sse"); + expect(getRequest.requestHeaders).toBeDefined(); + expect(getRequest.responseStatus).toBeDefined(); + } + } finally { + await client.disconnect(); + } + }); + + it("calls a tool over remote SSE", async () => { + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "sse", + url: mcpHttpServer.url, + }; + + const { client } = await setupRemoteAndConnect(config); + + try { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "echo"); + expect(tool).toBeDefined(); + const invocation = await client.callTool(tool!, { + message: "sse-test", + }); + expect(invocation.result?.content).toBeDefined(); + const textContent = invocation.result?.content?.find( + (c: { type: string }) => c.type === "text", + ); + expect((textContent as { type: "text"; text: string }).text).toContain( + "Echo: sse-test", + ); + } finally { + await client.disconnect(); + } + }); + }); + + describe("streamable-http", () => { + it("connects, lists tools, and receives fetch_request events over remote", async () => { + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + + const { client, fetchRequestLogState } = + await setupRemoteAndConnect(config); + + try { + expect(client.getStatus()).toBe("connected"); + + await client.listTools(); + + const fetchRequests = fetchRequestLogState.getFetchRequests(); + expect(fetchRequests.length).toBeGreaterThan(0); + const postRequest = fetchRequests.find((r) => r.method === "POST"); + expect(postRequest).toBeDefined(); + if (postRequest) { + expect(postRequest.url).toContain("/mcp"); + expect(postRequest.requestHeaders).toBeDefined(); + expect(postRequest.responseStatus).toBeDefined(); + expect(postRequest.responseHeaders).toBeDefined(); + expect(postRequest.duration).toBeDefined(); + } + } finally { + await client.disconnect(); + } + }); + + it("calls a tool over remote streamable-http", async () => { + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + + const { client } = await setupRemoteAndConnect(config); + + try { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "echo"); + expect(tool).toBeDefined(); + const invocation = await client.callTool(tool!, { + message: "streamable-http-test", + }); + expect(invocation.result?.content).toBeDefined(); + const textContent = invocation.result?.content?.find( + (c: { type: string }) => c.type === "text", + ); + expect((textContent as { type: "text"; text: string }).text).toContain( + "Echo: streamable-http-test", + ); + } finally { + await client.disconnect(); + } + }); + }); + + describe("authentication", () => { + it("rejects requests without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + expect(json.message).toContain("x-mcp-remote-auth"); + }); + + it("rejects requests with incorrect auth token", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer wrong-token-${authToken}`, + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests without Bearer prefix", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": authToken, // Missing "Bearer " prefix + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests to /api/fetch without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://example.com" }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests to /api/log without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: { label: "info" }, messages: ["test"] }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests to /api/mcp/send without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: "test-session", + message: { jsonrpc: "2.0", method: "test", id: 1 }, + }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests to /api/mcp/events without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/events?sessionId=test`, { + method: "GET", + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + + it("rejects requests to /api/mcp/disconnect without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "test-session" }), + }); + + expect(res.status).toBe(401); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Unauthorized"); + }); + }); + + describe("when dangerouslyOmitAuth is true", () => { + it("accepts /api/mcp/connect without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0, { + dangerouslyOmitAuth: true, + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + expect(res.status).not.toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).not.toBe("Unauthorized"); + }); + + it("accepts /api/log without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0, { + dangerouslyOmitAuth: true, + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: { label: "info" }, messages: ["test"] }), + }); + + expect(res.status).not.toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).not.toBe("Unauthorized"); + }); + + it("accepts /api/storage GET without auth token", async () => { + const { baseUrl, server } = await startRemoteServer(0, { + dangerouslyOmitAuth: true, + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + }); + + expect(res.status).not.toBe(401); + const json = (await res.json()) as { error?: string }; + expect(json.error).not.toBe("Unauthorized"); + }); + }); + + describe("remote logging", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("writes InspectorClient logs to file via createRemoteLogger over remote transport", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-log-test-")); + const logPath = join(tempDir!, "remote.log"); + const fileLogger = pino( + { level: "info" }, + pino.destination({ dest: logPath, append: true, mkdir: true }), + ); + + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + await mcpHttpServer.start(); + + const { baseUrl, server, authToken } = await startRemoteServer(0, { + logger: fileLogger, + }); + remoteServer = server; + + const createTransport = createRemoteTransport({ + baseUrl, + authToken, + }); + const remoteLogger = createRemoteLogger({ + baseUrl, + authToken, + fetchFn: fetch, + }); + const client = new InspectorClient( + { type: "sse", url: mcpHttpServer!.url }, + { + environment: { + transport: createTransport, + logger: remoteLogger, + }, + maxMessages: 100, + maxFetchRequests: 100, + maxStderrLogEvents: 100, + pipeStderr: true, + }, + ); + + await client.connect(); + await client.listTools(); + + // Wait for async log POSTs to complete and file logger to flush + await new Promise((resolve) => { + fileLogger.flush(() => resolve()); + }); + await new Promise((r) => setTimeout(r, 300)); + + const logContent = readFileSync(logPath, "utf-8"); + expect(logContent).toContain("transport fetch"); + expect(logContent).toContain("InspectorClient"); + expect(logContent).toContain("component"); + expect(logContent).toContain("category"); + + await client.disconnect(); + }); + }); + + describe("storage", () => { + let tempDir: string | null = null; + + beforeEach(() => { + tempDir = null; + }); + + afterEach(async () => { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("returns empty object for non-existent store", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({}); + }); + + it("reads and writes store data", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const testData = { key1: "value1", key2: { nested: "value" } }; + + // Write store + const writeRes = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + body: JSON.stringify(testData), + }); + + expect(writeRes.status).toBe(200); + const writeJson = await writeRes.json(); + expect(writeJson).toEqual({ ok: true }); + + // Read store + const readRes = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + + expect(readRes.status).toBe(200); + const readJson = await readRes.json(); + expect(readJson).toEqual(testData); + }); + + it("overwrites store on POST", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const initialData = { key1: "value1" }; + const updatedData = { key2: "value2" }; + + // Write initial data + await fetch(`${baseUrl}/api/storage/test-store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + body: JSON.stringify(initialData), + }); + + // Overwrite with new data + await fetch(`${baseUrl}/api/storage/test-store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + body: JSON.stringify(updatedData), + }); + + // Read and verify overwrite + const readRes = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + + expect(readRes.status).toBe(200); + const readJson = await readRes.json(); + expect(readJson).toEqual(updatedData); + expect(readJson).not.toEqual(initialData); + }); + + it("rejects invalid storeId", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + // Test invalid characters (not alphanumeric, hyphen, underscore) + const res = await fetch(`${baseUrl}/api/storage/invalid.store.id`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toBe("Invalid storeId"); + }); + + it("rejects requests without auth token", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + }); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error).toBe("Unauthorized"); + }); + + it("deletes store with DELETE endpoint", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const testData = { key1: "value1" }; + + // Write store + await fetch(`${baseUrl}/api/storage/test-store`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + body: JSON.stringify(testData), + }); + + // Verify it exists + const readRes = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(readRes.status).toBe(200); + const readJson = await readRes.json(); + expect(readJson).toEqual(testData); + + // Delete store + const deleteRes = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "DELETE", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(deleteRes.status).toBe(200); + const deleteJson = await deleteRes.json(); + expect(deleteJson).toEqual({ ok: true }); + + // Verify it's gone (returns empty object) + const readAfterDelete = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(readAfterDelete.status).toBe(200); + const readAfterDeleteJson = await readAfterDelete.json(); + expect(readAfterDeleteJson).toEqual({}); + }); + + it("DELETE returns success for non-existent store", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const deleteRes = await fetch(`${baseUrl}/api/storage/non-existent`, { + method: "DELETE", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(deleteRes.status).toBe(200); + const deleteJson = await deleteRes.json(); + expect(deleteJson).toEqual({ ok: true }); + }); + }); + + describe("Origin validation", () => { + it("allows requests with valid origin", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0, { + allowedOrigins: ["http://localhost:3000"], + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + Origin: "http://localhost:3000", + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + // Should not be blocked by origin validation (may fail for other reasons) + expect(res.status).not.toBe(403); + const json = (await res.json()) as { error?: string }; + // Should not be "Forbidden" due to origin + expect(json.error).not.toBe("Forbidden"); + }); + + it("blocks requests with invalid origin", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0, { + allowedOrigins: ["http://localhost:3000"], + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + Origin: "http://evil.com", + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + expect(res.status).toBe(403); + const json = (await res.json()) as { error?: string; message?: string }; + expect(json.error).toBe("Forbidden"); + expect(json.message).toContain("Invalid origin"); + }); + + it("allows requests without origin header (same-origin or non-browser)", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0, { + allowedOrigins: ["http://localhost:3000"], + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + // No Origin header + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + // Should not be blocked by origin validation + expect(res.status).not.toBe(403); + }); + + it("handles CORS preflight requests with valid origin", async () => { + const { baseUrl, server } = await startRemoteServer(0, { + allowedOrigins: ["http://localhost:3000"], + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:3000", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,x-mcp-remote-auth", + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:3000", + ); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + }); + + it("blocks CORS preflight requests with invalid origin", async () => { + const { baseUrl, server } = await startRemoteServer(0, { + allowedOrigins: ["http://localhost:3000"], + }); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "OPTIONS", + headers: { + Origin: "http://evil.com", + "Access-Control-Request-Method": "POST", + }, + }); + + expect(res.status).toBe(403); + const json = (await res.json()) as { error?: string }; + expect(json.error).toBe("Forbidden"); + }); + + it("allows all origins when allowedOrigins is not configured", async () => { + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const res = await fetch(`${baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-mcp-remote-auth": `Bearer ${authToken}`, + Origin: "http://any-origin.com", + }, + body: JSON.stringify({ + config: { type: "sse" as const, url: "http://localhost:3000" }, + }), + }); + + // Should not be blocked by origin validation + expect(res.status).not.toBe(403); + }); + }); +}); diff --git a/core/__tests__/storage-adapters.test.ts b/core/__tests__/storage-adapters.test.ts new file mode 100644 index 000000000..f259383c0 --- /dev/null +++ b/core/__tests__/storage-adapters.test.ts @@ -0,0 +1,326 @@ +/** + * Tests for storage adapters (file, remote). + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import { waitForRemoteStore } from "@modelcontextprotocol/inspector-test-server"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createFileStorageAdapter } from "../storage/adapters/file-storage.js"; +import { createRemoteStorageAdapter } from "../storage/adapters/remote-storage.js"; +import { createOAuthStore } from "../auth/store.js"; +import { createRemoteApp } from "../mcp/remote/node/server.js"; + +interface StartRemoteServerOptions { + storageDir?: string; +} + +async function startRemoteServer( + port: number, + options: StartRemoteServerOptions = {}, +): Promise<{ + baseUrl: string; + server: ServerType; + authToken: string; +}> { + const { app, authToken } = createRemoteApp({ + storageDir: options.storageDir, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port, hostname: "127.0.0.1" }, + (info) => { + const actualPort = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : port; + resolve({ + baseUrl: `http://127.0.0.1:${actualPort}`, + server, + authToken, + }); + }, + ); + server.on("error", reject); + }); +} + +describe("Storage adapters", () => { + describe("FileStorageAdapter", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("creates store and persists state", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const filePath = join(tempDir!, "test-store.json"); + const storage = createFileStorageAdapter({ filePath }); + const store = createOAuthStore(storage); + + // Set some state + store.getState().setServerState("https://example.com", { + tokens: { access_token: "test-token", token_type: "Bearer" }, + }); + + // Wait for persistence (Zustand persist is async; poll for file so we don't race with cleanup) + await vi.waitFor( + () => { + expect(existsSync(filePath)).toBe(true); + }, + { timeout: 2000, interval: 20 }, + ); + const fileContent = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(fileContent); + expect(parsed.state.servers["https://example.com"].tokens).toEqual({ + access_token: "test-token", + token_type: "Bearer", + }); + }); + + it("loads persisted state on initialization", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const filePath = join(tempDir!, "test-store.json"); + + // Create initial store and persist + const storage1 = createFileStorageAdapter({ filePath }); + const store1 = createOAuthStore(storage1); + store1.getState().setServerState("https://example.com", { + tokens: { access_token: "initial-token", token_type: "Bearer" }, + }); + await vi.waitFor( + () => { + expect(existsSync(filePath)).toBe(true); + }, + { timeout: 2000, interval: 20 }, + ); + + // Create new store instance (should load persisted state) + const storage2 = createFileStorageAdapter({ filePath }); + const store2 = createOAuthStore(storage2); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const state = store2.getState().getServerState("https://example.com"); + expect(state.tokens).toEqual({ + access_token: "initial-token", + token_type: "Bearer", + }); + }); + + it("handles empty state after clear", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const filePath = join(tempDir!, "test-store.json"); + const storage = createFileStorageAdapter({ filePath }); + const store = createOAuthStore(storage); + + // Set state and persist + store.getState().setServerState("https://example.com", { + tokens: { access_token: "test-token", token_type: "Bearer" }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(existsSync(filePath)).toBe(true); + + // Clear all servers (this will persist empty state) + const state = store.getState(); + const urls = Object.keys(state.servers); + for (const url of urls) { + state.clearServerState(url); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify file still exists but with empty servers + expect(existsSync(filePath)).toBe(true); + const fileContent = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(fileContent); + expect(Object.keys(parsed.state.servers).length).toBe(0); + }); + }); + + describe("RemoteStorageAdapter", () => { + let remoteServer: ServerType | null = null; + let tempDir: string | null = null; + + afterEach(async () => { + if (remoteServer) { + await new Promise((resolve, reject) => { + remoteServer!.close((err) => (err ? reject(err) : resolve())); + }); + remoteServer = null; + } + if (tempDir) { + try { + rmSync(tempDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + tempDir = null; + } + }); + + it("creates store and persists state via HTTP", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const storage = createRemoteStorageAdapter({ + baseUrl, + storeId: "test-store", + authToken, + }); + const store = createOAuthStore(storage); + + // Set some state + store.getState().setServerState("https://example.com", { + tokens: { access_token: "test-token", token_type: "Bearer" }, + }); + + await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { + const d = body as { + state?: { + servers?: Record; + }; + }; + return ( + d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + "test-token" + ); + }); + + // Verify via API + const res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(res.status).toBe(200); + const storeData = await res.json(); + expect(storeData.state.servers["https://example.com"].tokens).toEqual({ + access_token: "test-token", + token_type: "Bearer", + }); + }); + + it("loads persisted state on initialization", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + // Create initial store and persist + const storage1 = createRemoteStorageAdapter({ + baseUrl, + storeId: "test-store", + authToken, + }); + const store1 = createOAuthStore(storage1); + store1.getState().setServerState("https://example.com", { + tokens: { access_token: "initial-token", token_type: "Bearer" }, + }); + await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { + const d = body as { + state?: { + servers?: Record; + }; + }; + return ( + d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + "initial-token" + ); + }); + + // Create new store instance (should load persisted state) + const storage2 = createRemoteStorageAdapter({ + baseUrl, + storeId: "test-store", + authToken, + }); + const store2 = createOAuthStore(storage2); + await vi.waitFor( + () => { + const state = store2.getState().getServerState("https://example.com"); + if (!state.tokens) throw new Error("Store not yet hydrated"); + return state; + }, + { timeout: 2000, interval: 50 }, + ); + + const state = store2.getState().getServerState("https://example.com"); + expect(state.tokens).toEqual({ + access_token: "initial-token", + token_type: "Bearer", + }); + }); + + it("handles empty state after clear", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const { baseUrl, server, authToken } = await startRemoteServer(0, { + storageDir: tempDir, + }); + remoteServer = server; + + const storage = createRemoteStorageAdapter({ + baseUrl, + storeId: "test-store", + authToken, + }); + const store = createOAuthStore(storage); + + // Set state and persist + store.getState().setServerState("https://example.com", { + tokens: { access_token: "test-token", token_type: "Bearer" }, + }); + await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { + const d = body as { state?: { servers?: Record } }; + return !!d?.state?.servers && Object.keys(d.state.servers).length > 0; + }); + + // Verify it exists + let res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(res.status).toBe(200); + const storeData = await res.json(); + expect(Object.keys(storeData.state.servers).length).toBeGreaterThan(0); + + // Clear all servers (this will persist empty state) + const state = store.getState(); + const urls = Object.keys(state.servers); + for (const url of urls) { + state.clearServerState(url); + } + await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { + const d = body as { state?: { servers?: Record } }; + return !d?.state?.servers || Object.keys(d.state.servers).length === 0; + }); + + // Verify it's empty + res = await fetch(`${baseUrl}/api/storage/test-store`, { + method: "GET", + headers: { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }, + }); + expect(res.status).toBe(200); + const emptyStore = await res.json(); + expect(Object.keys(emptyStore.state.servers).length).toBe(0); + }); + }); +}); diff --git a/core/__tests__/transport.test.ts b/core/__tests__/transport.test.ts new file mode 100644 index 000000000..f43fc34d8 --- /dev/null +++ b/core/__tests__/transport.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from "vitest"; +import { getServerType } from "../mcp/config.js"; +import { createTransportNode } from "../mcp/node/transport.js"; +import type { MCPServerConfig, FetchRequestEntryBase } from "../mcp/types.js"; +import { + createTestServerHttp, + createEchoTool, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +describe("Transport", () => { + describe("getServerType", () => { + it("should return stdio for stdio config", () => { + const config: MCPServerConfig = { + type: "stdio", + command: "echo", + args: ["hello"], + }; + expect(getServerType(config)).toBe("stdio"); + }); + + it("should return sse for sse config", () => { + const config: MCPServerConfig = { + type: "sse", + url: "http://localhost:3000/sse", + }; + expect(getServerType(config)).toBe("sse"); + }); + + it("should return streamable-http for streamable-http config", () => { + const config: MCPServerConfig = { + type: "streamable-http", + url: "http://localhost:3000/mcp", + }; + expect(getServerType(config)).toBe("streamable-http"); + }); + + it("should default to stdio when type is not present", () => { + const config: MCPServerConfig = { + command: "echo", + args: ["hello"], + }; + expect(getServerType(config)).toBe("stdio"); + }); + + it("should throw error for invalid type", () => { + const config = { + type: "invalid", + command: "echo", + } as unknown as MCPServerConfig; + expect(() => getServerType(config)).toThrow(); + }); + }); + + describe("createTransport", () => { + it("should create stdio transport", () => { + const config: MCPServerConfig = { + type: "stdio", + command: "echo", + args: ["hello"], + }; + const result = createTransportNode(config); + expect(result.transport).toBeDefined(); + }); + + it("should create SSE transport", () => { + const config: MCPServerConfig = { + type: "sse", + url: "http://localhost:3000/sse", + }; + const result = createTransportNode(config); + expect(result.transport).toBeDefined(); + }); + + it("should create streamable-http transport", () => { + const config: MCPServerConfig = { + type: "streamable-http", + url: "http://localhost:3000/mcp", + }; + const result = createTransportNode(config); + expect(result.transport).toBeDefined(); + }); + + it("should call onFetchRequest callback for SSE transport", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "sse", + }); + + try { + await server.start(); + + const config: MCPServerConfig = { + type: "sse", + url: server.url, + }; + + const fetchRequests: FetchRequestEntryBase[] = []; + const result = createTransportNode(config, { + onFetchRequest: (entry) => { + fetchRequests.push(entry); + }, + }); + + expect(result.transport).toBeDefined(); + + // Actually connect and make a request to verify fetch tracking works + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await client.connect(result.transport); + await client.listTools(); + await client.close(); + + // Verify fetch requests were tracked + expect(fetchRequests.length).toBeGreaterThan(0); + // SSE uses GET for the initial connection + const getRequest = fetchRequests.find((r) => r.method === "GET"); + expect(getRequest).toBeDefined(); + if (getRequest) { + expect(getRequest.url).toContain("/sse"); + expect(getRequest.requestHeaders).toBeDefined(); + } + } finally { + await server.stop(); + } + }); + + it("should call onFetchRequest callback for streamable-http transport", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + + try { + await server.start(); + + const config: MCPServerConfig = { + type: "streamable-http", + url: server.url, + }; + + const fetchRequests: FetchRequestEntryBase[] = []; + const result = createTransportNode(config, { + onFetchRequest: (entry) => { + fetchRequests.push(entry); + }, + }); + + expect(result.transport).toBeDefined(); + + // Actually connect and make a request to verify fetch tracking works + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + }, + ); + + await client.connect(result.transport); + await client.listTools(); + await client.close(); + + // Verify fetch requests were tracked + expect(fetchRequests.length).toBeGreaterThan(0); + const request = fetchRequests[0]; + expect(request).toBeDefined(); + expect(request.url).toContain("/mcp"); + expect(request.method).toBe("POST"); + expect(request.requestHeaders).toBeDefined(); + expect(request.responseStatus).toBeDefined(); + expect(request.responseHeaders).toBeDefined(); + expect(request.duration).toBeDefined(); + } finally { + await server.stop(); + } + }); + }); +}); diff --git a/core/auth/browser/index.ts b/core/auth/browser/index.ts new file mode 100644 index 000000000..e0fd34111 --- /dev/null +++ b/core/auth/browser/index.ts @@ -0,0 +1,3 @@ +export { BrowserOAuthStorage } from "./storage.js"; +export { BrowserNavigation, BrowserOAuthClientProvider } from "./providers.js"; +export type { OAuthNavigationCallback } from "./providers.js"; diff --git a/core/auth/browser/providers.ts b/core/auth/browser/providers.ts new file mode 100644 index 000000000..bafc633b1 --- /dev/null +++ b/core/auth/browser/providers.ts @@ -0,0 +1,46 @@ +import type { + RedirectUrlProvider, + OAuthNavigationCallback, +} from "../providers.js"; +import { CallbackNavigation, BaseOAuthClientProvider } from "../providers.js"; +import { BrowserOAuthStorage } from "./storage.js"; + +export type { OAuthNavigationCallback } from "../providers.js"; + +/** + * Browser navigation handler + * Redirects the browser window to the authorization URL, optionally invokes an + * extra callback. + */ +export class BrowserNavigation extends CallbackNavigation { + constructor(callback?: OAuthNavigationCallback) { + super((url) => { + if (typeof window === "undefined") { + throw new Error("BrowserNavigation requires browser environment"); + } + window.location.href = url.href; + return callback?.(url); + }); + } +} + +/** + * Browser OAuth client provider + * Uses sessionStorage directly (for web client reference) + */ +export class BrowserOAuthClientProvider extends BaseOAuthClientProvider { + constructor(serverUrl: string) { + if (typeof window === "undefined") { + throw new Error( + "BrowserOAuthClientProvider requires browser environment", + ); + } + const storage = new BrowserOAuthStorage(); + const redirectUrlProvider: RedirectUrlProvider = { + getRedirectUrl: () => `${window.location.origin}/oauth/callback`, + }; + const navigation = new BrowserNavigation(); + + super(serverUrl, { storage, redirectUrlProvider, navigation }, "normal"); + } +} diff --git a/core/auth/browser/storage.ts b/core/auth/browser/storage.ts new file mode 100644 index 000000000..76ffdb999 --- /dev/null +++ b/core/auth/browser/storage.ts @@ -0,0 +1,145 @@ +import { createJSONStorage } from "zustand/middleware"; +import type { OAuthStorage } from "../storage.js"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthClientInformationSchema, + OAuthTokensSchema, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { createOAuthStore, type ServerOAuthState } from "../store.js"; + +/** + * Browser storage implementation using Zustand with sessionStorage. + * For web client (can be used by InspectorClient in browser). + */ +export class BrowserOAuthStorage implements OAuthStorage { + private store: ReturnType; + + constructor() { + // Use Zustand's built-in sessionStorage adapter + // The `name` option in persist() ("mcp-inspector-oauth") becomes the sessionStorage key + const storage = createJSONStorage(() => sessionStorage); + this.store = createOAuthStore(storage); + } + async getClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise { + const state = this.store.getState().getServerState(serverUrl); + const clientInfo = isPreregistered + ? state.preregisteredClientInformation + : state.clientInformation; + + if (!clientInfo) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(clientInfo); + } + + async saveClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + clientInformation, + }); + } + + async savePreregisteredClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + preregisteredClientInformation: clientInformation, + }); + } + + clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { + this.store.getState().getServerState(serverUrl); + const updates: Partial = {}; + + if (isPreregistered) { + updates.preregisteredClientInformation = undefined; + } else { + updates.clientInformation = undefined; + } + + this.store.getState().setServerState(serverUrl, updates); + } + + async getTokens(serverUrl: string): Promise { + const state = this.store.getState().getServerState(serverUrl); + if (!state.tokens) { + return undefined; + } + + return await OAuthTokensSchema.parseAsync(state.tokens); + } + + async saveTokens(serverUrl: string, tokens: OAuthTokens): Promise { + this.store.getState().setServerState(serverUrl, { tokens }); + } + + clearTokens(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { tokens: undefined }); + } + + getCodeVerifier(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.codeVerifier; + } + + async saveCodeVerifier( + serverUrl: string, + codeVerifier: string, + ): Promise { + this.store.getState().setServerState(serverUrl, { codeVerifier }); + } + + clearCodeVerifier(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { codeVerifier: undefined }); + } + + getScope(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.scope; + } + + async saveScope(serverUrl: string, scope: string | undefined): Promise { + this.store.getState().setServerState(serverUrl, { scope }); + } + + clearScope(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { scope: undefined }); + } + + getServerMetadata(serverUrl: string): OAuthMetadata | null { + const state = this.store.getState().getServerState(serverUrl); + return state.serverMetadata || null; + } + + async saveServerMetadata( + serverUrl: string, + metadata: OAuthMetadata, + ): Promise { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: metadata }); + } + + clearServerMetadata(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: undefined }); + } + + clear(serverUrl: string): void { + this.store.getState().clearServerState(serverUrl); + } +} diff --git a/core/auth/discovery.ts b/core/auth/discovery.ts new file mode 100644 index 000000000..ae1b33ef6 --- /dev/null +++ b/core/auth/discovery.ts @@ -0,0 +1,37 @@ +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes + * @param serverUrl - The MCP server URL + * @param resourceMetadata - Optional resource metadata containing preferred scopes + * @param fetchFn - Optional fetch function for HTTP requests (e.g. proxy fetch in browser) + * @returns Promise resolving to space-separated scope string or undefined + */ +export const discoverScopes = async ( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, + fetchFn?: typeof fetch, +): Promise => { + try { + const metadata = await discoverAuthorizationServerMetadata( + new URL("/", serverUrl), + { fetchFn }, + ); + + // Prefer resource metadata scopes, but fall back to OAuth metadata if empty + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = metadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 + ? resourceScopes + : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; + } catch { + return undefined; + } +}; diff --git a/core/auth/index.ts b/core/auth/index.ts new file mode 100644 index 000000000..467b55a49 --- /dev/null +++ b/core/auth/index.ts @@ -0,0 +1,47 @@ +// Types +export type { + OAuthStep, + OAuthAuthType, + MessageType, + StatusMessage, + AuthGuidedState, + CallbackParams, +} from "./types.js"; +export { EMPTY_GUIDED_STATE } from "./types.js"; + +// Storage +export type { OAuthStorage } from "./storage.js"; +export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; + +// Providers +export type { + OAuthProviderConfig, + RedirectUrlProvider, + OAuthNavigation, + OAuthNavigationCallback, +} from "./providers.js"; +export { + MutableRedirectUrlProvider, + ConsoleNavigation, + CallbackNavigation, + BaseOAuthClientProvider, +} from "./providers.js"; + +// Utilities +export { + parseOAuthCallbackParams, + generateOAuthState, + generateOAuthStateWithMode, + parseOAuthState, + generateOAuthErrorDescription, +} from "./utils.js"; +export type { OAuthStateMode } from "./utils.js"; + +// Discovery +export { discoverScopes } from "./discovery.js"; + +// Logging (re-exported from core/logging) +export { silentLogger } from "../logging/index.js"; +// State Machine +export type { StateMachineContext, StateTransition } from "./state-machine.js"; +export { oauthTransitions, OAuthStateMachine } from "./state-machine.js"; diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts new file mode 100644 index 000000000..cbd8476b4 --- /dev/null +++ b/core/auth/node/index.ts @@ -0,0 +1,16 @@ +export { + NodeOAuthStorage, + getOAuthStore, + getStateFilePath, + clearAllOAuthClientState, +} from "./storage-node.js"; +export { + createOAuthCallbackServer, + OAuthCallbackServer, +} from "./oauth-callback-server.js"; +export type { + OAuthCallbackHandler, + OAuthErrorHandler, + OAuthCallbackServerStartOptions, + OAuthCallbackServerStartResult, +} from "./oauth-callback-server.js"; diff --git a/core/auth/node/oauth-callback-server.ts b/core/auth/node/oauth-callback-server.ts new file mode 100644 index 000000000..8ee48d7d9 --- /dev/null +++ b/core/auth/node/oauth-callback-server.ts @@ -0,0 +1,216 @@ +import { createServer, type Server } from "node:http"; +import { parseOAuthCallbackParams } from "../utils.js"; +import { generateOAuthErrorDescription } from "../utils.js"; + +const DEFAULT_HOSTNAME = "127.0.0.1"; +const DEFAULT_CALLBACK_PATH = "/oauth/callback"; + +const SUCCESS_HTML = ` + +OAuth complete +

OAuth complete. You can close this window.

+`; + +function errorHtml(message: string): string { + return ` + +OAuth error +

OAuth failed: ${escapeHtml(message)}

+`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export type OAuthCallbackHandler = (params: { + code: string; + state?: string; +}) => Promise; + +export type OAuthErrorHandler = (params: { + error: string; + error_description?: string | null; +}) => void; + +export interface OAuthCallbackServerStartOptions { + port?: number; + hostname?: string; + path?: string; + onCallback?: OAuthCallbackHandler; + onError?: OAuthErrorHandler; +} + +export interface OAuthCallbackServerStartResult { + port: number; + redirectUrl: string; +} + +/** + * Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback. + * Used by TUI/CLI to complete the authorization code flow (both normal and guided). + * Caller provides onCallback/onError; typically onCallback calls + * InspectorClient.completeOAuthFlow(code) then stops the server. + */ +export class OAuthCallbackServer { + private server: Server | null = null; + private port: number = 0; + private hostname: string = DEFAULT_HOSTNAME; + private callbackPath: string = DEFAULT_CALLBACK_PATH; + private handled = false; + private onCallback?: OAuthCallbackHandler; + private onError?: OAuthErrorHandler; + + /** + * Start the server. Listens on the given port (default 0 = random). + * Returns port and redirectUrl for use as oauth.redirectUrl. + */ + async start( + options: OAuthCallbackServerStartOptions = {}, + ): Promise { + const { + port = 0, + hostname = DEFAULT_HOSTNAME, + path = DEFAULT_CALLBACK_PATH, + onCallback, + onError, + } = options; + if (!path.startsWith("/")) { + return Promise.reject( + new Error("Callback path must start with '/' (absolute path)"), + ); + } + this.onCallback = onCallback; + this.onError = onError; + this.handled = false; + this.hostname = hostname; + this.callbackPath = path; + + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)); + this.server.on("error", reject); + this.server.listen(port, hostname, () => { + const a = this.server!.address(); + if (!a || typeof a === "string") { + reject(new Error("Failed to get server address")); + return; + } + this.port = a.port; + resolve({ + port: this.port, + redirectUrl: buildRedirectUrl(hostname, this.port, path), + }); + }); + }); + } + + /** + * Stop the server. Idempotent. + */ + async stop(): Promise { + if (!this.server) return; + await new Promise((resolve) => { + this.server!.close(() => resolve()); + }); + this.server = null; + } + + private handleRequest( + req: import("node:http").IncomingMessage, + res: import("node:http").ServerResponse< + import("node:http").IncomingMessage + >, + ): void { + const needJson = req.headers["accept"]?.includes("application/json"); + + const send = ( + status: number, + body: string, + contentType = "text/html; charset=utf-8", + ) => { + res.writeHead(status, { "Content-Type": contentType }); + res.end(body); + }; + + if (req.method !== "GET") { + send(405, needJson ? '{"error":"Method Not Allowed"}' : SUCCESS_HTML); + return; + } + + let pathname: string; + let search: string; + let state: string | undefined; + try { + const u = new URL(req.url ?? "", "http://placeholder"); + pathname = u.pathname; + search = u.search; + state = u.searchParams.get("state") ?? undefined; + } catch { + send(400, needJson ? '{"error":"Bad Request"}' : SUCCESS_HTML); + return; + } + + if (pathname !== this.callbackPath) { + send(404, needJson ? '{"error":"Not Found"}' : SUCCESS_HTML); + return; + } + + if (this.handled) { + send( + 409, + needJson ? '{"error":"Callback already handled"}' : SUCCESS_HTML, + ); + return; + } + + const params = parseOAuthCallbackParams(search); + + if (params.successful) { + this.handled = true; + const cb = this.onCallback; + if (cb) { + cb({ code: params.code, state }) + .then(() => { + send(200, SUCCESS_HTML); + void this.stop(); + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + this.onError?.({ error: "callback_error", error_description: msg }); + send(500, errorHtml(msg)); + void this.stop(); + }); + } else { + send(200, SUCCESS_HTML); + void this.stop(); + } + return; + } + + this.handled = true; + const msg = generateOAuthErrorDescription(params); + this.onError?.({ + error: params.error, + error_description: params.error_description ?? undefined, + }); + send(400, errorHtml(msg)); + } +} + +/** + * Create an OAuth callback server instance. + * Use start() then stop() when the OAuth flow is done. + */ +export function createOAuthCallbackServer(): OAuthCallbackServer { + return new OAuthCallbackServer(); +} + +function buildRedirectUrl(host: string, port: number, path: string): string { + const needsBrackets = host.includes(":") && !host.startsWith("["); + const formattedHost = needsBrackets ? `[${host}]` : host; + return `http://${formattedHost}:${port}${path}`; +} diff --git a/core/auth/node/storage-node.ts b/core/auth/node/storage-node.ts new file mode 100644 index 000000000..ba1b3edf5 --- /dev/null +++ b/core/auth/node/storage-node.ts @@ -0,0 +1,193 @@ +import type { OAuthStorage } from "../storage.js"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthClientInformationSchema, + OAuthTokensSchema, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { createOAuthStore, type ServerOAuthState } from "../store.js"; +import { createFileStorageAdapter } from "../../storage/adapters/file-storage.js"; +import { + getDefaultStorageDir, + getStoreFilePath, +} from "../../storage/store-io.js"; + +/** Default path: ~/.mcp-inspector/storage/oauth.json */ +const DEFAULT_STATE_PATH = getStoreFilePath(getDefaultStorageDir(), "oauth"); + +/** + * Get path to OAuth state file. + * @param customPath - Optional custom path (full path to state file). Default: ~/.mcp-inspector/storage/oauth.json + */ +export function getStateFilePath(customPath?: string): string { + return customPath ?? DEFAULT_STATE_PATH; +} + +const storeCache = new Map>(); + +/** + * Get or create the OAuth store instance for the given path. + * @param stateFilePath - Optional custom path to state file. Default: ~/.mcp-inspector/storage/oauth.json + */ +export function getOAuthStore(stateFilePath?: string) { + const key = getStateFilePath(stateFilePath); + let store = storeCache.get(key); + if (!store) { + const filePath = getStateFilePath(stateFilePath); + const storage = createFileStorageAdapter({ filePath }); + store = createOAuthStore(storage); + storeCache.set(key, store); + } + return store; +} + +/** + * Clear all OAuth client state (all servers) in the default store. + * Useful for test isolation in E2E OAuth tests. + * Use a custom-path store and clear per serverUrl if you need to clear non-default storage. + */ +export function clearAllOAuthClientState(): void { + const store = getOAuthStore(); + const state = store.getState(); + const urls = Object.keys(state.servers ?? {}); + for (const url of urls) { + state.clearServerState(url); + } +} + +/** + * Node.js storage implementation using Zustand with file-based persistence + * For InspectorClient, CLI, and TUI + */ +export class NodeOAuthStorage implements OAuthStorage { + private store: ReturnType; + + /** + * @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/oauth/state.json + */ + constructor(storagePath?: string) { + this.store = getOAuthStore(storagePath); + } + + async getClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise { + const state = this.store.getState().getServerState(serverUrl); + const clientInfo = isPreregistered + ? state.preregisteredClientInformation + : state.clientInformation; + + if (!clientInfo) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(clientInfo); + } + + async saveClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + clientInformation, + }); + } + + async savePreregisteredClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + preregisteredClientInformation: clientInformation, + }); + } + + clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { + this.store.getState().getServerState(serverUrl); + const updates: Partial = {}; + + if (isPreregistered) { + updates.preregisteredClientInformation = undefined; + } else { + updates.clientInformation = undefined; + } + + this.store.getState().setServerState(serverUrl, updates); + } + + async getTokens(serverUrl: string): Promise { + const state = this.store.getState().getServerState(serverUrl); + if (!state.tokens) { + return undefined; + } + + return await OAuthTokensSchema.parseAsync(state.tokens); + } + + async saveTokens(serverUrl: string, tokens: OAuthTokens): Promise { + this.store.getState().setServerState(serverUrl, { tokens }); + } + + clearTokens(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { tokens: undefined }); + } + + getCodeVerifier(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.codeVerifier; + } + + async saveCodeVerifier( + serverUrl: string, + codeVerifier: string, + ): Promise { + this.store.getState().setServerState(serverUrl, { codeVerifier }); + } + + clearCodeVerifier(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { codeVerifier: undefined }); + } + + getScope(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.scope; + } + + async saveScope(serverUrl: string, scope: string | undefined): Promise { + this.store.getState().setServerState(serverUrl, { scope }); + } + + clearScope(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { scope: undefined }); + } + + getServerMetadata(serverUrl: string): OAuthMetadata | null { + const state = this.store.getState().getServerState(serverUrl); + return state.serverMetadata || null; + } + + async saveServerMetadata( + serverUrl: string, + metadata: OAuthMetadata, + ): Promise { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: metadata }); + } + + clearServerMetadata(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: undefined }); + } + + clear(serverUrl: string): void { + this.store.getState().clearServerState(serverUrl); + } +} diff --git a/core/auth/providers.ts b/core/auth/providers.ts new file mode 100644 index 000000000..444e1defd --- /dev/null +++ b/core/auth/providers.ts @@ -0,0 +1,255 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthStorage } from "./storage.js"; +import { generateOAuthStateWithMode } from "./utils.js"; + +/** + * Redirect URL provider. Returns the redirect URL for the requested mode. + * Caller populates the URLs before authenticate() (e.g. from callback server). + */ +export interface RedirectUrlProvider { + getRedirectUrl(mode?: "normal" | "guided"): string; +} + +/** + * Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl + * before authenticate(); same URL is used for both normal and guided flows. + */ +export class MutableRedirectUrlProvider implements RedirectUrlProvider { + redirectUrl = ""; + + getRedirectUrl(): string { + return this.redirectUrl; + } +} + +/** + * Navigation handler interface + * Handles navigation to authorization URLs + */ +export interface OAuthNavigation { + /** + * Navigate to the authorization URL + * @param authorizationUrl - The OAuth authorization URL + */ + navigateToAuthorization(authorizationUrl: URL): void; +} + +export type OAuthNavigationCallback = ( + authorizationUrl: URL, +) => void | Promise; + +/** + * Callback navigation handler + * Invokes the provided callback when navigation is requested. + * The caller always handles navigation. + */ +export class CallbackNavigation implements OAuthNavigation { + private authorizationUrl: URL | null = null; + + constructor(private callback: OAuthNavigationCallback) {} + + navigateToAuthorization(authorizationUrl: URL): void { + this.authorizationUrl = authorizationUrl; + const result = this.callback(authorizationUrl); + if (result instanceof Promise) { + void result; + } + } + + getAuthorizationUrl(): URL | null { + return this.authorizationUrl; + } +} + +/** + * Console navigation handler + * Prints the authorization URL to console, optionally invokes an extra callback. + */ +export class ConsoleNavigation extends CallbackNavigation { + constructor(callback?: OAuthNavigationCallback) { + super((url) => { + console.log(`Please navigate to: ${url.href}`); + return callback?.(url); + }); + } +} + +/** + * Config passed to BaseOAuthClientProvider. Provider assigns to members and + * accesses as needed. + */ +export type OAuthProviderConfig = { + storage: OAuthStorage; + redirectUrlProvider: RedirectUrlProvider; + navigation: OAuthNavigation; + clientMetadataUrl?: string; +}; + +/** + * Base OAuth client provider + * Implements common OAuth provider functionality. + * Use with injected storage, redirect URL provider, and navigation. + */ +export class BaseOAuthClientProvider implements OAuthClientProvider { + private capturedAuthUrl: URL | null = null; + private eventTarget: EventTarget | null = null; + + protected storage: OAuthStorage; + protected redirectUrlProvider: RedirectUrlProvider; + protected navigation: OAuthNavigation; + public clientMetadataUrl?: string; + protected mode: "normal" | "guided"; + + constructor( + protected serverUrl: string, + oauthConfig: OAuthProviderConfig, + mode: "normal" | "guided" = "normal", + ) { + this.storage = oauthConfig.storage; + this.redirectUrlProvider = oauthConfig.redirectUrlProvider; + this.navigation = oauthConfig.navigation; + this.clientMetadataUrl = oauthConfig.clientMetadataUrl; + this.mode = mode; + } + + /** + * Set the event target for dispatching oauthAuthorizationRequired events + */ + setEventTarget(eventTarget: EventTarget): void { + this.eventTarget = eventTarget; + } + + /** + * Get the captured authorization URL (for return value) + */ + getCapturedAuthUrl(): URL | null { + return this.capturedAuthUrl; + } + + /** + * Clear the captured authorization URL + */ + clearCapturedAuthUrl(): void { + this.capturedAuthUrl = null; + } + + get scope(): string | undefined { + return this.storage.getScope(this.serverUrl); + } + + /** Redirect URL for the current flow (normal or guided). */ + get redirectUrl(): string { + return this.redirectUrlProvider.getRedirectUrl(this.mode); + } + + get redirect_uris(): string[] { + return [this.redirectUrlProvider.getRedirectUrl("normal")]; + } + + get clientMetadata(): OAuthClientMetadata { + const metadata: OAuthClientMetadata = { + redirect_uris: this.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + client_uri: "https://github.com/modelcontextprotocol/inspector", + scope: this.scope ?? "", + }; + + // Note: clientMetadataUrl for CIMD mode is passed to registerClient() directly, + // not as part of clientMetadata. The SDK handles CIMD separately. + + return metadata; + } + + state(): string | Promise { + return generateOAuthStateWithMode(this.mode); + } + + async clientInformation(): Promise { + // Try preregistered first, then dynamically registered + const preregistered = await this.storage.getClientInformation( + this.serverUrl, + true, + ); + if (preregistered) { + return preregistered; + } + return await this.storage.getClientInformation(this.serverUrl, false); + } + + async saveClientInformation( + clientInformation: OAuthClientInformation, + ): Promise { + await this.storage.saveClientInformation(this.serverUrl, clientInformation); + } + + async saveScope(scope: string | undefined): Promise { + await this.storage.saveScope(this.serverUrl, scope); + } + + async savePreregisteredClientInformation( + clientInformation: OAuthClientInformation, + ): Promise { + await this.storage.savePreregisteredClientInformation( + this.serverUrl, + clientInformation, + ); + } + + async tokens(): Promise { + return await this.storage.getTokens(this.serverUrl); + } + + async saveTokens(tokens: OAuthTokens): Promise { + await this.storage.saveTokens(this.serverUrl, tokens); + } + + redirectToAuthorization(authorizationUrl: URL): void { + // Capture URL for return value + this.capturedAuthUrl = authorizationUrl; + + // Dispatch event if event target is set + if (this.eventTarget) { + this.eventTarget.dispatchEvent( + new CustomEvent("oauthAuthorizationRequired", { + detail: { url: authorizationUrl }, + }), + ); + } + + // Original navigation behavior + this.navigation.navigateToAuthorization(authorizationUrl); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier); + } + + codeVerifier(): string { + const verifier = this.storage.getCodeVerifier(this.serverUrl); + if (!verifier) { + throw new Error("No code verifier saved for session"); + } + return verifier; + } + + clear(): void { + this.storage.clear(this.serverUrl); + } + + getServerMetadata(): OAuthMetadata | null { + return this.storage.getServerMetadata(this.serverUrl); + } + + async saveServerMetadata(metadata: OAuthMetadata): Promise { + await this.storage.saveServerMetadata(this.serverUrl, metadata); + } +} diff --git a/core/auth/remote/index.ts b/core/auth/remote/index.ts new file mode 100644 index 000000000..8f3272956 --- /dev/null +++ b/core/auth/remote/index.ts @@ -0,0 +1,6 @@ +/** + * Remote HTTP storage for OAuth state. + */ + +export { RemoteOAuthStorage } from "./storage-remote.js"; +export type { RemoteOAuthStorageOptions } from "./storage-remote.js"; diff --git a/core/auth/remote/storage-remote.ts b/core/auth/remote/storage-remote.ts new file mode 100644 index 000000000..70bc0ea9d --- /dev/null +++ b/core/auth/remote/storage-remote.ts @@ -0,0 +1,167 @@ +/** + * Remote HTTP storage implementation for OAuth state. + * Uses Zustand with remote storage adapter (HTTP API). + * For web clients that need to share state with Node apps. + */ + +import type { OAuthStorage } from "../storage.js"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthClientInformationSchema, + OAuthTokensSchema, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { createOAuthStore, type ServerOAuthState } from "../store.js"; +import { createRemoteStorageAdapter } from "../../storage/adapters/remote-storage.js"; + +export interface RemoteOAuthStorageOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + /** Store ID (default: "oauth") */ + storeId?: string; + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + /** Fetch function to use (default: globalThis.fetch) */ + fetchFn?: typeof fetch; +} + +/** + * Remote HTTP storage implementation using Zustand with remote storage adapter. + * Stores OAuth state via HTTP API (GET/POST/DELETE /api/storage/:storeId). + * For web clients that need to share state with Node apps (TUI, CLI). + */ +export class RemoteOAuthStorage implements OAuthStorage { + private store: ReturnType; + + constructor(options: RemoteOAuthStorageOptions) { + const storage = createRemoteStorageAdapter({ + baseUrl: options.baseUrl, + storeId: options.storeId ?? "oauth", + authToken: options.authToken, + fetchFn: options.fetchFn, + }); + this.store = createOAuthStore(storage); + } + + async getClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise { + const state = this.store.getState().getServerState(serverUrl); + const clientInfo = isPreregistered + ? state.preregisteredClientInformation + : state.clientInformation; + + if (!clientInfo) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(clientInfo); + } + + async saveClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + clientInformation, + }); + } + + async savePreregisteredClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise { + this.store.getState().setServerState(serverUrl, { + preregisteredClientInformation: clientInformation, + }); + } + + clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { + this.store.getState().getServerState(serverUrl); + const updates: Partial = {}; + + if (isPreregistered) { + updates.preregisteredClientInformation = undefined; + } else { + updates.clientInformation = undefined; + } + + this.store.getState().setServerState(serverUrl, updates); + } + + async getTokens(serverUrl: string): Promise { + const state = this.store.getState().getServerState(serverUrl); + if (!state.tokens) { + return undefined; + } + + return await OAuthTokensSchema.parseAsync(state.tokens); + } + + async saveTokens(serverUrl: string, tokens: OAuthTokens): Promise { + this.store.getState().setServerState(serverUrl, { tokens }); + } + + clearTokens(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { tokens: undefined }); + } + + getCodeVerifier(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.codeVerifier; + } + + async saveCodeVerifier( + serverUrl: string, + codeVerifier: string, + ): Promise { + this.store.getState().setServerState(serverUrl, { codeVerifier }); + } + + clearCodeVerifier(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { codeVerifier: undefined }); + } + + getScope(serverUrl: string): string | undefined { + const state = this.store.getState().getServerState(serverUrl); + return state.scope; + } + + async saveScope(serverUrl: string, scope: string | undefined): Promise { + this.store.getState().setServerState(serverUrl, { scope }); + } + + clearScope(serverUrl: string): void { + this.store.getState().setServerState(serverUrl, { scope: undefined }); + } + + getServerMetadata(serverUrl: string): OAuthMetadata | null { + const state = this.store.getState().getServerState(serverUrl); + return state.serverMetadata || null; + } + + async saveServerMetadata( + serverUrl: string, + metadata: OAuthMetadata, + ): Promise { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: metadata }); + } + + clearServerMetadata(serverUrl: string): void { + this.store + .getState() + .setServerState(serverUrl, { serverMetadata: undefined }); + } + + clear(serverUrl: string): void { + this.store.getState().clearServerState(serverUrl); + } +} diff --git a/client/src/lib/oauth-state-machine.ts b/core/auth/state-machine.ts similarity index 59% rename from client/src/lib/oauth-state-machine.ts rename to core/auth/state-machine.ts index 8dc9da8f9..49f950718 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/core/auth/state-machine.ts @@ -1,5 +1,6 @@ -import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; +import type { OAuthStep, AuthGuidedState } from "./types.js"; +import type { BaseOAuthClientProvider } from "./providers.js"; +import { discoverScopes } from "./discovery.js"; import { discoverAuthorizationServerMetadata, registerClient, @@ -10,15 +11,15 @@ import { } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthMetadataSchema, - OAuthProtectedResourceMetadata, + type OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { generateOAuthState } from "@/utils/oauthUtils"; export interface StateMachineContext { - state: AuthDebuggerState; + state: AuthGuidedState; serverUrl: string; - provider: DebugInspectorOAuthClientProvider; - updateState: (updates: Partial) => void; + provider: BaseOAuthClientProvider; + updateState: (updates: Partial) => void; + fetchFn?: typeof fetch; } export interface StateTransition { @@ -32,15 +33,18 @@ export const oauthTransitions: Record = { canTransition: async () => true, execute: async (context) => { // Default to discovering from the server's URL - let authServerUrl = new URL("/", context.serverUrl); + let authServerUrl: URL = new URL("/", context.serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl, + context.serverUrl as string | URL, ); if (resourceMetadata?.authorization_servers?.length) { - authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + const firstServer = resourceMetadata.authorization_servers[0]; + if (firstServer) { + authServerUrl = new URL(firstServer); + } } } catch (e) { if (e instanceof Error) { @@ -50,19 +54,27 @@ export const oauthTransitions: Record = { } } - const resource: URL | undefined = await selectResourceURL( - context.serverUrl, - context.provider, - // we default to null, so swap it for undefined if not set - resourceMetadata ?? undefined, - ); + const resource: URL | undefined = resourceMetadata + ? await selectResourceURL( + context.serverUrl, + context.provider, + resourceMetadata, + ) + : undefined; - const metadata = await discoverAuthorizationServerMetadata(authServerUrl); + const metadata = await discoverAuthorizationServerMetadata( + authServerUrl, + { + ...(context.fetchFn && { fetchFn: context.fetchFn }), + }, + ); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - context.provider.saveServerMetadata(parsedMetadata); + + await context.provider.saveServerMetadata(parsedMetadata); + context.updateState({ resourceMetadata, resource, @@ -92,14 +104,39 @@ export const oauthTransitions: Record = { } } - // Try Static client first, with DCR as fallback - let fullInformation = await context.provider.clientInformation(); + // Use pre-set client info from state (static client) when present; otherwise provider lookup → CIMD → DCR + let fullInformation = + context.state.oauthClientInfo ?? + (await context.provider.clientInformation()); if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); - context.provider.saveClientInformation(fullInformation); + // Check if provider has clientMetadataUrl (CIMD mode) + const clientMetadataUrl = + "clientMetadataUrl" in context.provider && + context.provider.clientMetadataUrl + ? context.provider.clientMetadataUrl + : undefined; + + // Check for CIMD support (SDK handles this in authInternal - we replicate it here) + const supportsUrlBasedClientId = + metadata?.client_id_metadata_document_supported === true; + const shouldUseUrlBasedClientId = + supportsUrlBasedClientId && clientMetadataUrl; + + if (shouldUseUrlBasedClientId) { + // SEP-991: URL-based Client IDs (CIMD) + // SDK creates { client_id: clientMetadataUrl } directly - no registration needed + fullInformation = { + client_id: clientMetadataUrl, + }; + } else { + // Fallback to DCR registration + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + ...(context.fetchFn && { fetchFn: context.fetchFn }), + }); + } + await context.provider.saveClientInformation(fullInformation); } context.updateState({ @@ -122,9 +159,12 @@ export const oauthTransitions: Record = { scope = await discoverScopes( context.serverUrl, context.state.resourceMetadata ?? undefined, + context.fetchFn, ); } + const providerState = context.provider.state(); + const state = await Promise.resolve(providerState); const { authorizationUrl, codeVerifier } = await startAuthorization( context.serverUrl, { @@ -132,12 +172,12 @@ export const oauthTransitions: Record = { clientInformation, redirectUrl: context.provider.redirectUrl, scope, - state: generateOAuthState(), + state, resource: context.state.resource ?? undefined, }, ); - context.provider.saveCodeVerifier(codeVerifier); + await context.provider.saveCodeVerifier(codeVerifier); context.updateState({ authorizationUrl: authorizationUrl, oauthStep: "authorization_code", @@ -167,16 +207,26 @@ export const oauthTransitions: Record = { token_request: { canTransition: async (context) => { - return ( - !!context.state.authorizationCode && - !!context.provider.getServerMetadata() && - !!(await context.provider.clientInformation()) - ); + const hasMetadata = !!context.provider.getServerMetadata(); + const clientInfo = + context.state.oauthClientInfo ?? + (await context.provider.clientInformation()); + return !!context.state.authorizationCode && hasMetadata && !!clientInfo; }, execute: async (context) => { const codeVerifier = context.provider.codeVerifier(); - const metadata = context.provider.getServerMetadata()!; - const clientInformation = (await context.provider.clientInformation())!; + const metadata = context.provider.getServerMetadata(); + + if (!metadata) { + throw new Error("OAuth metadata not available"); + } + + const clientInformation = + context.state.oauthClientInfo ?? + (await context.provider.clientInformation()); + if (!clientInformation) { + throw new Error("Client information not available for token exchange"); + } const tokens = await exchangeAuthorization(context.serverUrl, { metadata, @@ -189,9 +239,10 @@ export const oauthTransitions: Record = { ? context.state.resource : new URL(context.state.resource) : undefined, + ...(context.fetchFn && { fetchFn: context.fetchFn }), }); - context.provider.saveTokens(tokens); + await context.provider.saveTokens(tokens); context.updateState({ oauthTokens: tokens, oauthStep: "complete", @@ -210,16 +261,18 @@ export const oauthTransitions: Record = { export class OAuthStateMachine { constructor( private serverUrl: string, - private updateState: (updates: Partial) => void, + private provider: BaseOAuthClientProvider, + private updateState: (updates: Partial) => void, + private fetchFn?: typeof fetch, ) {} - async executeStep(state: AuthDebuggerState): Promise { - const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); + async executeStep(state: AuthGuidedState): Promise { const context: StateMachineContext = { state, serverUrl: this.serverUrl, - provider, + provider: this.provider, updateState: this.updateState, + ...(this.fetchFn && { fetchFn: this.fetchFn }), }; const transition = oauthTransitions[state.oauthStep]; diff --git a/core/auth/storage.ts b/core/auth/storage.ts new file mode 100644 index 000000000..6cbe13b5b --- /dev/null +++ b/core/auth/storage.ts @@ -0,0 +1,127 @@ +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Abstract storage interface for OAuth state + * Supports both browser (sessionStorage) and Node.js (Zustand) environments + */ +export interface OAuthStorage { + /** + * Get client information (preregistered or dynamically registered) + */ + getClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise; + + /** + * Save client information (dynamically registered) + */ + saveClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise; + + /** + * Save preregistered client information (static client from config) + */ + savePreregisteredClientInformation( + serverUrl: string, + clientInformation: OAuthClientInformation, + ): Promise; + + /** + * Clear client information + */ + clearClientInformation(serverUrl: string, isPreregistered?: boolean): void; + + /** + * Get OAuth tokens + */ + getTokens(serverUrl: string): Promise; + + /** + * Save OAuth tokens + */ + saveTokens(serverUrl: string, tokens: OAuthTokens): Promise; + + /** + * Clear OAuth tokens + */ + clearTokens(serverUrl: string): void; + + /** + * Get code verifier (for PKCE) + */ + getCodeVerifier(serverUrl: string): string | undefined; + + /** + * Save code verifier (for PKCE) + */ + saveCodeVerifier(serverUrl: string, codeVerifier: string): Promise; + + /** + * Clear code verifier + */ + clearCodeVerifier(serverUrl: string): void; + + /** + * Get scope + */ + getScope(serverUrl: string): string | undefined; + + /** + * Save scope + */ + saveScope(serverUrl: string, scope: string | undefined): Promise; + + /** + * Clear scope + */ + clearScope(serverUrl: string): void; + + /** + * Get server metadata (for guided mode) + */ + getServerMetadata(serverUrl: string): OAuthMetadata | null; + + /** + * Save server metadata (for guided mode) + */ + saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise; + + /** + * Clear server metadata + */ + clearServerMetadata(serverUrl: string): void; + + /** + * Clear all OAuth data for a server + */ + clear(serverUrl: string): void; +} + +/** + * Generate server-specific storage key + */ +export function getServerSpecificKey( + baseKey: string, + serverUrl: string, +): string { + return `[${serverUrl}] ${baseKey}`; +} + +/** + * Base storage keys for OAuth data + */ +export const OAUTH_STORAGE_KEYS = { + CODE_VERIFIER: "mcp_code_verifier", + TOKENS: "mcp_tokens", + CLIENT_INFORMATION: "mcp_client_information", + PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", + SERVER_METADATA: "mcp_server_metadata", + SCOPE: "mcp_scope", +} as const; diff --git a/core/auth/store.ts b/core/auth/store.ts new file mode 100644 index 000000000..c7c10516d --- /dev/null +++ b/core/auth/store.ts @@ -0,0 +1,81 @@ +/** + * OAuth store factory using Zustand. + * Creates a store with any storage adapter (file, remote, sessionStorage). + */ + +import { createStore } from "zustand/vanilla"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { + OAuthClientInformation, + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * OAuth state for a single server + */ +export interface ServerOAuthState { + clientInformation?: OAuthClientInformation; + preregisteredClientInformation?: OAuthClientInformation; + tokens?: OAuthTokens; + codeVerifier?: string; + scope?: string; + serverMetadata?: OAuthMetadata; +} + +/** + * Zustand store state (all servers) + */ +export interface OAuthStoreState { + servers: Record; + getServerState: (serverUrl: string) => ServerOAuthState; + setServerState: (serverUrl: string, state: Partial) => void; + clearServerState: (serverUrl: string) => void; +} + +/** + * Creates a Zustand store for OAuth state with the given storage adapter. + * The storage adapter handles persistence (file, remote HTTP, sessionStorage, etc.). + * + * @param storage - Zustand storage adapter (from createJSONStorage) + * @returns Zustand store instance + */ +export function createOAuthStore( + storage: ReturnType, +) { + return createStore()( + persist( + (set, get) => ({ + servers: {}, + getServerState: (serverUrl: string) => { + return get().servers[serverUrl] || {}; + }, + setServerState: ( + serverUrl: string, + updates: Partial, + ) => { + set((state) => ({ + servers: { + ...state.servers, + [serverUrl]: { + ...state.servers[serverUrl], + ...updates, + }, + }, + })); + }, + clearServerState: (serverUrl: string) => { + set((state) => { + const rest = { ...state.servers }; + delete rest[serverUrl]; + return { servers: rest }; + }); + }, + }), + { + name: "mcp-inspector-oauth", + storage, + }, + ), + ); +} diff --git a/core/auth/types.ts b/core/auth/types.ts new file mode 100644 index 000000000..77f4a5557 --- /dev/null +++ b/core/auth/types.ts @@ -0,0 +1,94 @@ +import type { + OAuthMetadata, + OAuthClientInformation, + OAuthClientInformationFull, + OAuthTokens, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// OAuth flow steps +export type OAuthStep = + | "metadata_discovery" + | "client_registration" + | "authorization_redirect" + | "authorization_code" + | "token_request" + | "complete"; + +// Message types for inline feedback +export type MessageType = "success" | "error" | "info"; + +export interface StatusMessage { + type: MessageType; + message: string; +} + +// How the current auth flow was started (guided = state machine with step events; normal = SDK auth()) +export type OAuthAuthType = "guided" | "normal"; + +// Single state interface for OAuth state +export interface AuthGuidedState { + /** How this auth flow was started; determines which fields are populated. */ + authType: OAuthAuthType; + /** When auth reached step "complete" (ms since epoch), if applicable. */ + completedAt: number | null; + isInitiatingAuth: boolean; + oauthTokens: OAuthTokens | null; + oauthStep: OAuthStep; + resourceMetadata: OAuthProtectedResourceMetadata | null; + resourceMetadataError: Error | null; + resource: URL | null; + authServerUrl: URL | null; + oauthMetadata: OAuthMetadata | null; + oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; + authorizationUrl: URL | null; + authorizationCode: string; + latestError: Error | null; + statusMessage: StatusMessage | null; + validationError: string | null; +} + +export const EMPTY_GUIDED_STATE: AuthGuidedState = { + authType: "guided", + completedAt: null, + isInitiatingAuth: false, + oauthTokens: null, + oauthStep: "metadata_discovery", + oauthMetadata: null, + resourceMetadata: null, + resourceMetadataError: null, + resource: null, + authServerUrl: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, +}; + +// The parsed query parameters returned by the Authorization Server +// representing either a valid authorization_code or an error +// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2 +export type CallbackParams = + | { + successful: true; + // The authorization code is generated by the authorization server. + code: string; + } + | { + successful: false; + // The OAuth 2.1 Error Code. + // Usually one of: + // ``` + // invalid_request, unauthorized_client, access_denied, unsupported_response_type, + // invalid_scope, server_error, temporarily_unavailable + // ``` + error: string; + // Human-readable ASCII text providing additional information, used to assist the + // developer in understanding the error that occurred. + error_description: string | null; + // A URI identifying a human-readable web page with information about the error, + // used to provide the client developer with additional information about the error. + error_uri: string | null; + }; diff --git a/core/auth/utils.ts b/core/auth/utils.ts new file mode 100644 index 000000000..5b6799f9c --- /dev/null +++ b/core/auth/utils.ts @@ -0,0 +1,111 @@ +import type { CallbackParams } from "./types.js"; + +/** + * Parses OAuth 2.1 callback parameters from a URL search string + * @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied") + * @returns Parsed callback parameters with success/error information + */ +export const parseOAuthCallbackParams = (location: string): CallbackParams => { + const params = new URLSearchParams(location); + + const code = params.get("code"); + if (code) { + return { successful: true, code }; + } + + const error = params.get("error"); + const error_description = params.get("error_description"); + const error_uri = params.get("error_uri"); + + if (error) { + return { successful: false, error, error_description, error_uri }; + } + + return { + successful: false, + error: "invalid_request", + error_description: "Missing code or error in response", + error_uri: null, + }; +}; + +/** + * Generate a random state for the OAuth 2.0 flow. + * Works in both browser and Node.js environments. + * + * @returns A random state for the OAuth 2.0 flow. + */ +export const generateOAuthState = (): string => { + // Generate a random state + const array = new Uint8Array(32); + + // Use crypto.getRandomValues (available in both browser and Node.js) + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + crypto.getRandomValues(array); + } else { + // Fallback for environments without crypto.getRandomValues + // This should not happen in modern environments + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + } + + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +}; + +export type OAuthStateMode = "normal" | "guided"; + +/** + * Generate OAuth state with mode prefix for single-redirect-URL flow. + * Format: {mode}:{authId} (e.g. "guided:a1b2c3..."). + * The authId part is 64 hex chars for CSRF protection and serves as session identifier. + */ +export const generateOAuthStateWithMode = (mode: OAuthStateMode): string => { + const authId = generateOAuthState(); + return `${mode}:${authId}`; +}; + +/** + * Parse OAuth state to extract mode and authId part. + * Returns null if invalid. + * Legacy state (plain 64-char hex, no prefix) is treated as mode "normal". + */ +export const parseOAuthState = ( + state: string, +): { mode: OAuthStateMode; authId: string } | null => { + if (!state || typeof state !== "string") return null; + if (state.startsWith("normal:")) { + return { mode: "normal", authId: state.slice(7) }; + } + if (state.startsWith("guided:")) { + return { mode: "guided", authId: state.slice(7) }; + } + // Legacy: plain 64-char hex + if (/^[a-f0-9]{64}$/i.test(state)) { + return { mode: "normal", authId: state }; + } + return null; +}; + +/** + * Generates a human-readable error description from OAuth callback error parameters + * @param params OAuth error callback parameters containing error details + * @returns Formatted multiline error message with error code, description, and optional URI + */ +export const generateOAuthErrorDescription = ( + params: Extract, +): string => { + const error = params.error; + const errorDescription = params.error_description; + const errorUri = params.error_uri; + + return [ + `Error: ${error}.`, + errorDescription ? `Details: ${errorDescription}.` : "", + errorUri ? `More info: ${errorUri}.` : "", + ] + .filter(Boolean) + .join("\n"); +}; diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts new file mode 100644 index 000000000..610c8befe --- /dev/null +++ b/core/json/jsonUtils.ts @@ -0,0 +1,101 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +/** + * JSON value type used across the inspector project + */ +export type JsonValue = + | string + | number + | boolean + | null + | undefined + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Simple schema type for parameter conversion + */ +type ParameterSchema = { + type?: string; +}; + +/** + * Convert a string parameter value to the appropriate JSON type based on schema + * @param value String value to convert + * @param schema Schema type information + * @returns Converted JSON value + */ +export function convertParameterValue( + value: string, + schema: ParameterSchema, +): JsonValue { + if (!value) { + return value; + } + + if (schema.type === "number" || schema.type === "integer") { + return Number(value); + } + + if (schema.type === "boolean") { + return value.toLowerCase() === "true"; + } + + if (schema.type === "object" || schema.type === "array") { + try { + return JSON.parse(value) as JsonValue; + } catch { + return value; + } + } + + return value; +} + +/** + * Convert string parameters to JSON values based on tool schema + * @param tool Tool definition with input schema + * @param params String parameters to convert + * @returns Converted parameters as JSON values + */ +export function convertToolParameters( + tool: Tool, + params: Record, +): Record { + const result: Record = {}; + const properties = tool.inputSchema?.properties || {}; + + for (const [key, value] of Object.entries(params)) { + const paramSchema = properties[key] as ParameterSchema | undefined; + + if (paramSchema) { + result[key] = convertParameterValue(value, paramSchema); + } else { + // If no schema is found for this parameter, keep it as string + result[key] = value; + } + } + + return result; +} + +/** + * Convert prompt arguments (JsonValue) to strings for prompt API + * @param args Prompt arguments as JsonValue + * @returns String arguments for prompt API + */ +export function convertPromptArguments( + args: Record, +): Record { + const stringArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + stringArgs[key] = value; + } else if (value === null || value === undefined) { + stringArgs[key] = String(value); + } else { + stringArgs[key] = JSON.stringify(value); + } + } + return stringArgs; +} diff --git a/core/logging/index.ts b/core/logging/index.ts new file mode 100644 index 000000000..6abcce77a --- /dev/null +++ b/core/logging/index.ts @@ -0,0 +1 @@ +export { silentLogger } from "./logger.js"; diff --git a/core/logging/logger.ts b/core/logging/logger.ts new file mode 100644 index 000000000..3b39a4361 --- /dev/null +++ b/core/logging/logger.ts @@ -0,0 +1,7 @@ +import pino from "pino"; + +/** + * Silent logger for use when no logger is injected. Satisfies pino.Logger, + * does not output anything. InspectorClient uses this as the default. + */ +export const silentLogger = pino({ level: "silent" }); diff --git a/core/mcp/config.ts b/core/mcp/config.ts new file mode 100644 index 000000000..1d1e034b4 --- /dev/null +++ b/core/mcp/config.ts @@ -0,0 +1,24 @@ +import type { MCPServerConfig, ServerType } from "./types.js"; + +/** + * Returns the transport type for an MCP server configuration. + * If type is omitted, defaults to "stdio". Throws if type is invalid. + */ +export function getServerType(config: MCPServerConfig): ServerType { + if (!("type" in config) || config.type === undefined) { + return "stdio"; + } + const type = config.type; + if (type === "stdio") { + return "stdio"; + } + if (type === "sse") { + return "sse"; + } + if (type === "streamable-http") { + return "streamable-http"; + } + throw new Error( + `Invalid server type: ${type}. Valid types are: stdio, sse, streamable-http`, + ); +} diff --git a/core/mcp/elicitationCreateMessage.ts b/core/mcp/elicitationCreateMessage.ts new file mode 100644 index 000000000..5003d053b --- /dev/null +++ b/core/mcp/elicitationCreateMessage.ts @@ -0,0 +1,65 @@ +import type { + ElicitRequest, + ElicitResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Represents a pending elicitation request from the server + */ +export class ElicitationCreateMessage { + public readonly id: string; + public readonly timestamp: Date; + public readonly request: ElicitRequest; + public readonly taskId?: string; + private resolvePromise?: (result: ElicitResult) => void; + /** Set only for task-augmented elicit; used when user declines so server's tasks/result receives an error */ + private rejectCallback?: (error: Error) => void; + + constructor( + request: ElicitRequest, + resolve: (result: ElicitResult) => void, + private onRemove: (id: string) => void, + reject?: (error: Error) => void, + ) { + this.id = `elicitation-${Date.now()}-${Math.random()}`; + this.timestamp = new Date(); + this.request = request; + // Extract taskId from request params metadata if present + const relatedTask = request.params?._meta?.[RELATED_TASK_META_KEY]; + this.taskId = relatedTask?.taskId; + this.resolvePromise = resolve; + this.rejectCallback = reject; + } + + /** + * Reject the elicitation (e.g. when user declines). Only has effect when this + * request was task-augmented; then the server's tasks/result will receive the error. + */ + reject(error: Error): void { + if (this.rejectCallback) { + this.rejectCallback(error); + this.rejectCallback = undefined; + } + } + + /** + * Respond to the elicitation request with a result + */ + async respond(result: ElicitResult): Promise { + if (!this.resolvePromise) { + throw new Error("Request already resolved"); + } + this.resolvePromise(result); + this.resolvePromise = undefined; + // Remove from pending list after responding + this.remove(); + } + + /** + * Remove this pending elicitation from the list + */ + remove(): void { + this.onRemove(this.id); + } +} diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts new file mode 100644 index 000000000..916427b2c --- /dev/null +++ b/core/mcp/fetchTracking.ts @@ -0,0 +1,151 @@ +import type { FetchRequestEntryBase } from "./types.js"; + +export interface FetchTrackingCallbacks { + trackRequest?: (entry: FetchRequestEntryBase) => void; +} + +/** + * Creates a fetch wrapper that tracks HTTP requests and responses + */ +export function createFetchTracker( + baseFetch: typeof fetch, + callbacks: FetchTrackingCallbacks, +): typeof fetch { + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const startTime = Date.now(); + const timestamp = new Date(); + const id = `${timestamp.getTime()}-${Math.random().toString(36).substr(2, 9)}`; + + // Extract request information + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method || "GET"; + + // Extract headers + const requestHeaders: Record = {}; + if (input instanceof Request) { + input.headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + } + if (init?.headers) { + const headers = new Headers(init.headers); + headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + } + + // Extract body (if present and readable) + let requestBody: string | undefined; + if (init?.body) { + if (typeof init.body === "string") { + requestBody = init.body; + } else { + // Try to convert to string, but skip if it fails (e.g., ReadableStream) + try { + requestBody = String(init.body); + } catch { + requestBody = undefined; + } + } + } else if (input instanceof Request && input.body) { + // Try to clone and read the request body + // Clone protects the original body from being consumed + try { + const cloned = input.clone(); + requestBody = await cloned.text(); + } catch { + // Can't read body (might be consumed, not readable, or other issue) + requestBody = undefined; + } + } + + // Make the actual fetch request + let response: Response; + let error: string | undefined; + try { + response = await baseFetch(input, init); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + // Create a minimal error entry + const entry: FetchRequestEntryBase = { + id, + timestamp, + method, + url, + requestHeaders, + requestBody, + error, + duration: Date.now() - startTime, + }; + callbacks.trackRequest?.(entry); + throw err; + } + + // Extract response information + const responseStatus = response.status; + const responseStatusText = response.statusText; + + // Extract response headers + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + // Check if this is a streaming response - if so, skip body reading entirely + // For streamable-http POST requests to /mcp, the response is always a stream + // that the transport needs to consume, so we should never try to read it + const contentType = response.headers.get("content-type"); + const isStream = + contentType?.includes("text/event-stream") || + contentType?.includes("application/x-ndjson") || + (method === "POST" && url.includes("/mcp")); + + let responseBody: string | undefined; + let duration: number; + + if (isStream) { + // For streams, don't try to read the body - just record metadata and return immediately + // The transport needs to consume the stream, so we can't clone/read it + duration = Date.now() - startTime; + } else { + // For regular responses, try to read the body (clone so we don't consume it) + if (response.body && !response.bodyUsed) { + try { + const cloned = response.clone(); + responseBody = await cloned.text(); + } catch { + // Can't read body (might be consumed, not readable, or other issue) + responseBody = undefined; + } + } + duration = Date.now() - startTime; + } + + // Create entry and track it + const entry: FetchRequestEntryBase = { + id, + timestamp, + method, + url, + requestHeaders, + requestBody, + responseStatus, + responseStatusText, + responseHeaders, + responseBody, + duration, + }; + + callbacks.trackRequest?.(entry); + + return response; + }; +} diff --git a/core/mcp/index.ts b/core/mcp/index.ts new file mode 100644 index 000000000..d2afb8e7e --- /dev/null +++ b/core/mcp/index.ts @@ -0,0 +1,53 @@ +// Main MCP client module +// Re-exports the primary API for MCP client/server interaction + +export { InspectorClient } from "./inspectorClient.js"; +export type { + InspectorClientOptions, + InspectorClientEnvironment, + AppRendererClient, +} from "./types.js"; + +// Re-export type-safe event target types for consumers +export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js"; + +export { getServerType } from "./config.js"; + +// Re-export types used by consumers +export type { + // Transport factory types (required by InspectorClient) + CreateTransport, + CreateTransportOptions, + CreateTransportResult, + // Config types + MCPConfig, + MCPServerConfig, + ServerType, + // Connection and state types (used by components and hooks) + ConnectionStatus, + StderrLogEntry, + MessageEntry, + FetchRequestEntry, + FetchRequestEntryBase, + FetchRequestCategory, + ServerState, + // Invocation types (returned from InspectorClient methods) + ResourceReadInvocation, + ResourceTemplateReadInvocation, + PromptGetInvocation, + ToolCallInvocation, +} from "./types.js"; + +// Re-export JSON utilities +export type { JsonValue } from "../json/jsonUtils.js"; +export { + convertParameterValue, + convertToolParameters, + convertPromptArguments, +} from "../json/jsonUtils.js"; + +// Re-export session storage types +export type { + InspectorClientStorage, + InspectorClientSessionState, +} from "./sessionStorage.js"; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts new file mode 100644 index 000000000..26b4fb0a2 --- /dev/null +++ b/core/mcp/inspectorClient.ts @@ -0,0 +1,2169 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { + MCPServerConfig, + StderrLogEntry, + ConnectionStatus, + MessageEntry, + FetchRequestEntry, + FetchRequestEntryBase, + ResourceReadInvocation, + ResourceTemplateReadInvocation, + PromptGetInvocation, + ToolCallInvocation, + AppRendererClient, + InspectorClientOptions, +} from "./types.js"; +import { getServerType as getServerTypeFromConfig } from "./config.js"; +import corePackageJson from "../package.json" with { type: "json" }; +import type { + CreateTransport, + CreateTransportOptions, + ServerType, +} from "./types.js"; +import { + MessageTrackingTransport, + type MessageTrackingCallbacks, +} from "./messageTrackingTransport.js"; +import type { + CallToolRequest, + JSONRPCRequest, + JSONRPCNotification, + JSONRPCResultResponse, + JSONRPCErrorResponse, + ServerCapabilities, + ClientCapabilities, + Implementation, + LoggingLevel, + Tool, + Resource, + ResourceTemplate, + Prompt, + Root, + CreateMessageResult, + ElicitResult, + CallToolResult, + Task, + Progress, + ProgressToken, + ListToolsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListPromptsRequest, + ReadResourceRequest, + GetPromptRequest, + CompleteRequest, +} from "@modelcontextprotocol/sdk/types.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { + RequestOptions, + ProgressCallback, +} from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + EmptyResultSchema, + ListRootsRequestSchema, + ElicitationCompleteNotificationSchema, + RootsListChangedNotificationSchema, + ToolListChangedNotificationSchema, + ResourceListChangedNotificationSchema, + PromptListChangedNotificationSchema, + ResourceUpdatedNotificationSchema, + CallToolResultSchema, + McpError, + ErrorCode, + ListTasksRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + CancelTaskRequestSchema, + TaskStatusNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { ClientResult } from "@modelcontextprotocol/sdk/types.js"; +import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; +import { + type JsonValue, + convertToolParameters, + convertPromptArguments, +} from "../json/jsonUtils.js"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; +import { + InspectorClientEventTarget, + type TaskWithOptionalCreatedAt, +} from "./inspectorClientEventTarget.js"; +import { SamplingCreateMessage } from "./samplingCreateMessage.js"; +import { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; +import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type pino from "pino"; +import { silentLogger } from "../logging/logger.js"; +import { createFetchTracker } from "./fetchTracking.js"; +import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; + +/** Internal record for a receiver task (server polls us for status/result). */ +interface ReceiverTaskRecord { + task: Task; + payloadPromise: Promise; + resolvePayload: (payload: ClientResult) => void; + rejectPayload: (reason?: unknown) => void; + cleanupTimeoutId?: ReturnType; +} + +/** + * InspectorClient wraps an MCP Client and provides: + * - Message tracking and storage + * - Stderr log tracking and storage (for stdio transports) + * - EventTarget interface for React hooks (cross-platform: works in browser and Node.js) + * - Access to client functionality (prompts, resources, tools) + */ +export class InspectorClient extends InspectorClientEventTarget { + private client: Client | null = null; + private appRendererClientProxy: AppRendererClient | null = null; + private transport: Transport | MessageTrackingTransport | null = null; + private baseTransport: Transport | null = null; + private pipeStderr: boolean; + private initialLoggingLevel?: LoggingLevel; + private sample: boolean; + private elicit: boolean | { form?: boolean; url?: boolean }; + private progress: boolean; + private resetTimeoutOnProgress: boolean; + private requestTimeout: number | undefined; + private status: ConnectionStatus = "disconnected"; + // Server data (resources, resourceTemplates, prompts are in state managers) + private capabilities?: ServerCapabilities; + private serverInfo?: Implementation; + private instructions?: string; + // Sampling requests + private pendingSamples: SamplingCreateMessage[] = []; + // Elicitation requests + private pendingElicitations: ElicitationCreateMessage[] = []; + // Roots (undefined means roots capability not enabled, empty array means enabled but no roots) + private roots: Root[] | undefined; + // Content cache + // ListChanged notification configuration + private listChangedNotifications: { + tools: boolean; + resources: boolean; + prompts: boolean; + }; + // Resource subscriptions + private subscribedResources: Set = new Set(); + // Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us) + private receiverTasks: boolean; + private receiverTaskTtlMs: number | (() => number); + private receiverTaskRecords: Map = new Map(); + // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") + private oauthManager: OAuthManager | null = null; + private logger: pino.Logger; + private transportClientFactory: CreateTransport; + private fetchFn?: typeof fetch; + private effectiveAuthFetch: typeof fetch; + // Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState) + private sessionId?: string; + + constructor( + private transportConfig: MCPServerConfig, + options: InspectorClientOptions, + ) { + super(); + // Extract environment components + this.transportClientFactory = options.environment.transport; + this.fetchFn = options.environment.fetch; + this.logger = options.environment.logger ?? silentLogger; + + // Initialize content cache + this.pipeStderr = options.pipeStderr ?? false; + this.initialLoggingLevel = options.initialLoggingLevel; + this.sample = options.sample ?? true; + this.elicit = options.elicit ?? true; + this.receiverTasks = options.receiverTasks ?? false; + this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000; + this.progress = options.progress ?? true; + this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true; + this.requestTimeout = options.timeout; + // Only set roots if explicitly provided (even if empty array) - this enables roots capability + this.roots = options.roots; + // Initialize listChangedNotifications config (default: all enabled) + this.listChangedNotifications = { + tools: options.listChangedNotifications?.tools ?? true, + resources: options.listChangedNotifications?.resources ?? true, + prompts: options.listChangedNotifications?.prompts ?? true, + }; + + // Effective auth fetch: base fetch + tracking with category 'auth' + this.effectiveAuthFetch = this.buildEffectiveAuthFetch(); + + this.sessionId = options.sessionId; + + // Merge OAuth config with environment components; create internal OAuth manager (owns config) + if (options.oauth || options.environment.oauth) { + const oauthConfig: OAuthManagerConfig = { + // Environment components (storage, navigation, redirectUrlProvider) + ...options.environment.oauth, + // Config values (clientId, clientSecret, clientMetadataUrl, scope) + ...options.oauth, + }; + this.oauthManager = new OAuthManager({ + getServerUrl: () => this.getServerUrl(), + effectiveAuthFetch: this.effectiveAuthFetch, + getEventTarget: () => this, + onBeforeOAuthRedirect: (sessionId: string) => { + this.sessionId = sessionId; + this.saveSession(); + return Promise.resolve(); + }, + initialConfig: oauthConfig, + dispatchOAuthStepChange: (detail) => + this.dispatchTypedEvent("oauthStepChange", detail), + dispatchOAuthComplete: (detail) => + this.dispatchTypedEvent("oauthComplete", detail), + dispatchOAuthAuthorizationRequired: (detail) => + this.dispatchTypedEvent("oauthAuthorizationRequired", detail), + dispatchOAuthError: (detail) => + this.dispatchTypedEvent("oauthError", detail), + }); + } + + // Transport is created in connect() (single place for create / wrap / attach). + + // Build client capabilities + const clientOptions: { capabilities?: ClientCapabilities } = {}; + const capabilities: ClientCapabilities = {}; + if (this.sample) { + capabilities.sampling = {}; + } + // Handle elicitation capability with mode support + if (this.elicit) { + const elicitationCap: NonNullable = {}; + + if (this.elicit === true) { + // Backward compatibility: `elicit: true` means form support only + elicitationCap.form = {}; + } else { + // Explicit mode configuration + if (this.elicit.form) { + elicitationCap.form = {}; + } + if (this.elicit.url) { + elicitationCap.url = {}; + } + } + + // Only add elicitation capability if at least one mode is enabled + if (Object.keys(elicitationCap).length > 0) { + capabilities.elicitation = elicitationCap; + } + } + // Advertise roots capability if roots option was provided (even if empty array) + if (this.roots !== undefined) { + capabilities.roots = { listChanged: true }; + } + // Receiver tasks: advertise so server can send task-augmented createMessage/elicit and poll us + if (this.receiverTasks) { + capabilities.tasks = { + list: {}, + cancel: {}, + requests: { + sampling: { createMessage: {} }, + elicitation: { create: {} }, + }, + }; + } + if (Object.keys(capabilities).length > 0) { + clientOptions.capabilities = capabilities; + } + + this.appRendererClientProxy = null; + this.client = new Client( + options.clientIdentity ?? { + name: corePackageJson.name.split("/")[1] ?? corePackageJson.name, + version: corePackageJson.version, + }, + Object.keys(clientOptions).length > 0 ? clientOptions : undefined, + ); + } + + private buildEffectiveAuthFetch(): typeof fetch { + const base = this.fetchFn ?? fetch; + return createFetchTracker(base, { + trackRequest: (entry) => + this.dispatchFetchRequest({ ...entry, category: "auth" }), + }); + } + + private createMessageTrackingCallbacks(): MessageTrackingCallbacks { + return { + trackRequest: (message: JSONRPCRequest) => { + const entry: MessageEntry = { + id: `${Date.now()}-${Math.random()}`, + timestamp: new Date(), + direction: "request", + message, + }; + this.dispatchTypedEvent("message", entry); + }, + trackResponse: ( + message: JSONRPCResultResponse | JSONRPCErrorResponse, + ) => { + const entry: MessageEntry = { + id: `${Date.now()}-${Math.random()}`, + timestamp: new Date(), + direction: "response", + message, + }; + this.dispatchTypedEvent("message", entry); + }, + trackNotification: (message: JSONRPCNotification) => { + const entry: MessageEntry = { + id: `${Date.now()}-${Math.random()}`, + timestamp: new Date(), + direction: "notification", + message, + }; + this.dispatchTypedEvent("message", entry); + }, + }; + } + + private attachTransportListeners(baseTransport: Transport): void { + baseTransport.onclose = () => { + if (this.status !== "disconnected") { + this.status = "disconnected"; + this.dispatchTypedEvent("statusChange", this.status); + this.dispatchTypedEvent("disconnect"); + } + }; + baseTransport.onerror = (error: Error) => { + this.status = "error"; + this.dispatchTypedEvent("statusChange", this.status); + this.dispatchTypedEvent("error", error); + }; + } + + /** + * Build RequestOptions for SDK client calls (timeout, resetTimeoutOnProgress, onprogress). + * When timeout is unset, SDK uses DEFAULT_REQUEST_TIMEOUT_MSEC (60s). + * + * When progress is enabled, we pass a per-request onprogress so the SDK routes progress and + * runs timeout reset. The SDK injects progressToken: messageId; we do not expose the caller's + * token to the server. We collect it from metadata and inject it into dispatched progressNotification + * events only, so listeners can correlate progress with the request that triggered it. + * + * @param progressToken Optional token from request metadata; injected into progressNotification + * events when provided (not sent to server). + */ + private getRequestOptions(progressToken?: ProgressToken): RequestOptions { + const opts: RequestOptions = { + resetTimeoutOnProgress: this.resetTimeoutOnProgress, + }; + if (this.requestTimeout !== undefined) { + opts.timeout = this.requestTimeout; + } + if (this.progress) { + const token = progressToken; + const onprogress: ProgressCallback = (progress: Progress) => { + const payload: Progress & { progressToken?: ProgressToken } = { + ...progress, + ...(token != null && { progressToken: token }), + }; + this.dispatchTypedEvent("progressNotification", payload); + }; + opts.onprogress = onprogress; + } + return opts; + } + + private isHttpOAuthConfig(): boolean { + const serverType = getServerTypeFromConfig(this.transportConfig); + return ( + (serverType === "sse" || serverType === "streamable-http") && + !!this.oauthManager + ); + } + + /** + * True when task status is completed, failed, or cancelled. + * We use this private helper instead of the SDK's experimental isTerminal() + * to avoid depending on experimental API and to get a type predicate so + * TypeScript narrows status to "completed" | "failed" | "cancelled" after the check. + */ + private static isTerminalTaskStatus( + status: Task["status"], + ): status is "completed" | "failed" | "cancelled" { + return ( + status === "completed" || status === "failed" || status === "cancelled" + ); + } + + private createReceiverTask(opts: { + ttl?: number; + initialStatus: Task["status"]; + statusMessage?: string; + pollInterval?: number; + }): ReceiverTaskRecord { + const taskId = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + const ttlMs = + opts.ttl ?? + (typeof this.receiverTaskTtlMs === "function" + ? this.receiverTaskTtlMs() + : this.receiverTaskTtlMs); + const now = new Date().toISOString(); + const task: Task = { + taskId, + status: opts.initialStatus, + ttl: ttlMs, + createdAt: now, + lastUpdatedAt: now, + ...(opts.pollInterval != null && { pollInterval: opts.pollInterval }), + ...(opts.statusMessage != null && { statusMessage: opts.statusMessage }), + }; + let resolvePayload!: (payload: ClientResult) => void; + let rejectPayload!: (reason?: unknown) => void; + const payloadPromise = new Promise((resolve, reject) => { + resolvePayload = resolve; + rejectPayload = reject; + }); + const record: ReceiverTaskRecord = { + task, + payloadPromise, + resolvePayload, + rejectPayload, + }; + record.cleanupTimeoutId = setTimeout(() => { + record.cleanupTimeoutId = undefined; + this.receiverTaskRecords.delete(taskId); + }, ttlMs); + this.receiverTaskRecords.set(taskId, record); + return record; + } + + private emitReceiverTaskStatus(task: Task): void { + if (!this.client) return; + try { + const notification = TaskStatusNotificationSchema.parse({ + method: "notifications/tasks/status" as const, + params: task, + }); + this.client.notification(notification).catch((err) => { + this.logger.warn( + { err, taskId: task.taskId }, + "receiver task status notification failed", + ); + }); + } catch (err) { + this.logger.warn( + { err, taskId: task.taskId }, + "receiver task status notification failed", + ); + } + } + + private upsertReceiverTask(updatedTask: Task): void { + const record = this.receiverTaskRecords.get(updatedTask.taskId); + if (record) { + record.task = updatedTask; + this.emitReceiverTaskStatus(updatedTask); + } + } + + private getReceiverTask(taskId: string): ReceiverTaskRecord | undefined { + return this.receiverTaskRecords.get(taskId); + } + + private listReceiverTasks(): Task[] { + return Array.from(this.receiverTaskRecords.values()).map((r) => r.task); + } + + private async getReceiverTaskPayload(taskId: string): Promise { + const record = this.receiverTaskRecords.get(taskId); + if (!record) { + throw new McpError(ErrorCode.InvalidParams, `Unknown taskId: ${taskId}`); + } + return record.payloadPromise; + } + + private cancelReceiverTask(taskId: string): Task { + const record = this.receiverTaskRecords.get(taskId); + if (!record) { + throw new McpError(ErrorCode.InvalidParams, `Unknown taskId: ${taskId}`); + } + if (InspectorClient.isTerminalTaskStatus(record.task.status)) { + return record.task; + } + const now = new Date().toISOString(); + const updatedTask: Task = { + ...record.task, + status: "cancelled", + lastUpdatedAt: now, + }; + record.task = updatedTask; + record.rejectPayload(new Error("Task cancelled")); + if (record.cleanupTimeoutId != null) { + clearTimeout(record.cleanupTimeoutId); + record.cleanupTimeoutId = undefined; + } + this.emitReceiverTaskStatus(updatedTask); + return updatedTask; + } + + /** + * Connect to the MCP server + */ + async connect(): Promise { + if (!this.client) { + throw new Error("Client not initialized"); + } + if (this.status === "connected") { + return; + } + + // Create transport (single place for create / wrap / attach). + if (!this.baseTransport) { + const transportOptions: CreateTransportOptions = { + fetchFn: this.fetchFn, + pipeStderr: this.pipeStderr, + onStderr: (entry: StderrLogEntry) => { + this.dispatchStderrLog(entry); + }, + onFetchRequest: (entry: FetchRequestEntryBase) => { + this.dispatchFetchRequest({ ...entry, category: "transport" }); + }, + }; + const oauthManager = this.oauthManager; + if (this.isHttpOAuthConfig() && oauthManager) { + const provider = await oauthManager.createOAuthProviderForTransport(); + transportOptions.authProvider = provider; + } + const { transport: baseTransport } = this.transportClientFactory( + this.transportConfig, + transportOptions, + ); + this.baseTransport = baseTransport; + const messageTracking = this.createMessageTrackingCallbacks(); + this.transport = new MessageTrackingTransport( + baseTransport, + messageTracking, + ); + this.attachTransportListeners(this.baseTransport); + } + + if (!this.transport) { + throw new Error("Transport not initialized"); + } + + try { + this.status = "connecting"; + this.dispatchTypedEvent("statusChange", this.status); + + await this.client.connect(this.transport); + this.status = "connected"; + this.dispatchTypedEvent("statusChange", this.status); + this.dispatchTypedEvent("connect"); + + // Always fetch server info (capabilities, serverInfo, instructions) - this is just cached data from initialize + await this.fetchServerInfo(); + + // Set initial logging level if configured and server supports it + if (this.initialLoggingLevel && this.capabilities?.logging) { + await this.client.setLoggingLevel( + this.initialLoggingLevel, + this.getRequestOptions(), + ); + } + + // Set up sampling request handler if sampling capability is enabled + if (this.sample && this.client) { + this.client.setRequestHandler(CreateMessageRequestSchema, (request) => { + const paramsTask = (request.params as { task?: { ttl?: number } }) + ?.task; + if (this.receiverTasks && paramsTask != null) { + const record = this.createReceiverTask({ + ttl: paramsTask.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + void (async () => { + const samplingRequest = new SamplingCreateMessage( + request, + (result) => { + record.resolvePayload(result); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: now, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (error) => { + record.rejectPayload(error); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: now, + statusMessage: + error instanceof Error ? error.message : String(error), + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (id) => this.removePendingSample(id), + ); + this.addPendingSample(samplingRequest); + })(); + return Promise.resolve({ task: record.task }); + } + return new Promise((resolve, reject) => { + const samplingRequest = new SamplingCreateMessage( + request, + (result) => { + resolve(result); + }, + (error) => { + reject(error); + }, + (id) => this.removePendingSample(id), + ); + this.addPendingSample(samplingRequest); + }); + }); + } + + // Set up elicitation request handler if elicitation capability is enabled + if (this.elicit && this.client) { + this.client.setRequestHandler(ElicitRequestSchema, (request) => { + const paramsTask = (request.params as { task?: { ttl?: number } }) + ?.task; + if (this.receiverTasks && paramsTask != null) { + const record = this.createReceiverTask({ + ttl: paramsTask.ttl, + initialStatus: "input_required", + statusMessage: "Awaiting user input", + }); + void (async () => { + const elicitationRequest = new ElicitationCreateMessage( + request, + (result) => { + record.resolvePayload(result); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "completed", + lastUpdatedAt: now, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + (id) => this.removePendingElicitation(id), + (error) => { + record.rejectPayload(error); + const now = new Date().toISOString(); + const updated: Task = { + ...record.task, + status: "failed", + lastUpdatedAt: now, + statusMessage: error.message, + }; + record.task = updated; + this.upsertReceiverTask(updated); + }, + ); + this.addPendingElicitation(elicitationRequest); + })(); + return Promise.resolve({ task: record.task }); + } + return new Promise((resolve) => { + const elicitationRequest = new ElicitationCreateMessage( + request, + (result) => { + resolve(result); + }, + (id) => this.removePendingElicitation(id), + ); + this.addPendingElicitation(elicitationRequest); + }); + }); + } + + // Set up roots/list request handler if roots capability is enabled + if (this.roots !== undefined && this.client) { + this.client.setRequestHandler(ListRootsRequestSchema, async () => { + return { roots: this.roots ?? [] }; + }); + } + + // Set up receiver-task request handlers (server polls us for tasks/list, tasks/get, tasks/result, tasks/cancel) + if (this.receiverTasks && this.client) { + this.client.setRequestHandler(ListTasksRequestSchema, async () => ({ + tasks: this.listReceiverTasks(), + })); + this.client.setRequestHandler(GetTaskRequestSchema, async (req) => { + const record = this.getReceiverTask(req.params.taskId); + if (!record) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown taskId: ${req.params.taskId}`, + ); + } + return record.task; + }); + this.client.setRequestHandler( + GetTaskPayloadRequestSchema, + async (req) => this.getReceiverTaskPayload(req.params.taskId), + ); + this.client.setRequestHandler(CancelTaskRequestSchema, async (req) => + this.cancelReceiverTask(req.params.taskId), + ); + } + + // Set up notification handler for roots/list_changed from server + if (this.client) { + this.client.setNotificationHandler( + RootsListChangedNotificationSchema, + async () => { + // Dispatch event to notify UI that server's roots may have changed + // Note: rootsChange is a CustomEvent with Root[] payload, not a signal event + // We'll reload roots when the UI requests them, so we don't need to pass data here + // For now, we'll just dispatch an empty array as a signal to reload + this.dispatchTypedEvent("rootsChange", this.roots || []); + }, + ); + } + + // Set up listChanged notification handlers based on config + if (this.client) { + // Tools listChanged handler + // Only register if both client config and server capability are enabled + if ( + this.listChangedNotifications.tools && + this.capabilities?.tools?.listChanged + ) { + this.client.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + // Always fire notification event (for tracking) + this.dispatchTypedEvent("toolsListChanged"); + // Tools are managed by state managers; they can listen to toolsListChanged and refresh + }, + ); + } + // Note: If handler should not be registered, we don't set it + // The SDK client will ignore notifications for which no handler is registered + + // Resources listChanged handler (state managers listen and refresh) + if ( + this.listChangedNotifications.resources && + this.capabilities?.resources?.listChanged + ) { + this.client.setNotificationHandler( + ResourceListChangedNotificationSchema, + async () => { + this.dispatchTypedEvent("resourcesListChanged"); + this.dispatchTypedEvent("resourceTemplatesListChanged"); + }, + ); + } + + // Prompts listChanged handler (state managers listen and refresh) + if ( + this.listChangedNotifications.prompts && + this.capabilities?.prompts?.listChanged + ) { + this.client.setNotificationHandler( + PromptListChangedNotificationSchema, + async () => { + this.dispatchTypedEvent("promptsListChanged"); + }, + ); + } + + // Tasks list_changed and status handlers (when server advertises tasks capability) + if (this.capabilities?.tasks) { + this.client.setNotificationHandler( + TasksListChangedNotificationSchema, + async () => { + this.dispatchTypedEvent("tasksListChanged"); + }, + ); + this.client.setNotificationHandler( + TaskStatusNotificationSchema, + async (notification) => { + const task = notification.params as Task; + this.dispatchTypedEvent("taskStatusChange", { + taskId: task.taskId, + task, + }); + }, + ); + } + + // Resource updated notification handler (only if server supports subscriptions) + if (this.capabilities?.resources?.subscribe === true) { + this.client.setNotificationHandler( + ResourceUpdatedNotificationSchema, + async (notification) => { + const uri = notification.params.uri; + // Only process if we're subscribed to this resource + if (this.subscribedResources.has(uri)) { + this.dispatchTypedEvent("resourceUpdated", { uri }); + } + }, + ); + } + + // Elicitation complete notification (URL mode only): server notifies when out-of-band + // elicitation completes; we resolve the corresponding pending elicitation + const urlElicitEnabled = + this.elicit && + typeof this.elicit === "object" && + this.elicit.url === true; + if (urlElicitEnabled) { + this.client.setNotificationHandler( + ElicitationCompleteNotificationSchema, + async (notification) => { + const { elicitationId } = notification.params; + const pending = this.pendingElicitations.find( + (e) => + e.request.params?.mode === "url" && + e.request.params?.elicitationId === elicitationId, + ); + if (pending) { + pending.remove(); + } + }, + ); + } + + // Progress: we use per-request onprogress (see getRequestOptions). We do not register + // a progress notification handler so the Protocol's _onprogress stays; timeout reset + // and routing work, and we inject the caller's progressToken into dispatched events. + } + } catch (error) { + this.status = "error"; + this.dispatchTypedEvent("statusChange", this.status); + this.dispatchTypedEvent( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } + } + + /** + * Disconnect from the MCP server. + * @param safeDisconnectTimeout If > 0, poll every 10ms until SDK _responseHandlers is empty or this many ms have elapsed, then close. Default 0 = close immediately. + */ + async disconnect(safeDisconnectTimeout = 0): Promise { + if (this.client) { + if (safeDisconnectTimeout > 0) { + // This is pretty creepy, but there are test cases where client calls return but there + // are still response handlers pending. Usually a single macrotask delay is enough to + // clear them, but not always (it's been >10ms in some cases). The pending handlers + // themselves get the error (and in cases where those aren't awaited, the errors fly + // out of the test). This workaround where we directly access the handlers (otherwise + // private member of the SDK client) is creepy, but the least ugly working solution. + // We will re-valuate this with the v2 SDK. Currenly only tests that do quick disconnects + // use this setting. + // + const protocol = this.client as unknown as { + _responseHandlers?: Map; + }; + const handlers = protocol._responseHandlers; + const deadline = Date.now() + safeDisconnectTimeout; + while ( + handlers?.size !== undefined && + handlers.size > 0 && + Date.now() < deadline + ) { + await new Promise((r) => setTimeout(r, 10)); + } + } + try { + await this.client.close(); + } catch { + // Ignore errors on close + } + } + // Null out transport so next connect() creates a fresh one. + this.baseTransport = null; + this.transport = null; + // Update status - transport onclose handler will also fire and clear state + // But we also do it here in case disconnect() is called directly + if (this.status !== "disconnected") { + this.status = "disconnected"; + this.dispatchTypedEvent("statusChange", this.status); + this.dispatchTypedEvent("disconnect"); + } + + // Clear server state on disconnect (list state is in state managers) + this.pendingSamples = []; + this.pendingElicitations = []; + // Clear resource subscriptions on disconnect + this.subscribedResources.clear(); + // Clear receiver tasks: stop TTL timers and drop records + for (const record of this.receiverTaskRecords.values()) { + if (record.cleanupTimeoutId != null) { + clearTimeout(record.cleanupTimeoutId); + } + } + this.receiverTaskRecords.clear(); + this.appRendererClientProxy = null; + this.capabilities = undefined; + this.serverInfo = undefined; + this.instructions = undefined; + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); + this.dispatchTypedEvent("capabilitiesChange", this.capabilities); + this.dispatchTypedEvent("serverInfoChange", this.serverInfo); + this.dispatchTypedEvent("instructionsChange", this.instructions); + } + + /** + * Returns a client proxy for use by AppRenderer / @mcp-ui. Delegates to the + * internal MCP Client. Returns null when not connected. Use this instead of + * accessing the raw client so behavior can be adapted here later if needed. + */ + getAppRendererClient(): AppRendererClient | null { + if (!this.client || this.status !== "connected") return null; + if (this.appRendererClientProxy !== null) + return this.appRendererClientProxy; + const target = this.client; + this.appRendererClientProxy = new Proxy(this.client, { + get(proxyTarget, prop, receiver) { + const value = Reflect.get(proxyTarget, prop, receiver); + if (prop === "setNotificationHandler" && typeof value === "function") { + return (...args: Parameters) => { + // Add behavior here (e.g. wrap handler, log, filter) + return value.apply(target, args); + }; + } + return value; + }, + }) as AppRendererClient; + return this.appRendererClientProxy; + } + + /** + * Send a ping request to the server. Resolves when the server responds. + */ + async ping(): Promise { + if (!this.client) { + throw new Error("Client not initialized"); + } + await this.client.request( + { method: "ping" }, + EmptyResultSchema, + this.getRequestOptions(), + ); + } + + /** + * Get the current connection status + */ + getStatus(): ConnectionStatus { + return this.status; + } + + /** + * Get the MCP server configuration used to create this client + */ + getTransportConfig(): MCPServerConfig { + return this.transportConfig; + } + + /** + * Get the server type (stdio, sse, or streamable-http) + */ + getServerType(): ServerType { + return getServerTypeFromConfig(this.transportConfig); + } + + /** + * Get task capabilities from server + * @returns Task capabilities or undefined if not supported + */ + getTaskCapabilities(): { list: boolean; cancel: boolean } | undefined { + if (!this.capabilities?.tasks) { + return undefined; + } + return { + list: !!this.capabilities.tasks.list, + cancel: !!this.capabilities.tasks.cancel, + }; + } + + /** + * Get requestor task status by taskId (tasks we created on the server) + * @param taskId Task identifier + * @returns Task status + */ + async getRequestorTask(taskId: string): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + const task = await this.client.experimental.tasks.getTask( + taskId, + this.getRequestOptions(), + ); + + // Dispatch client-origin event (taskStatusChange is server-only) + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId: task.taskId, + task: task, + }); + return task; + } + + /** + * Get requestor task result by taskId (tasks we created on the server) + * @param taskId Task identifier + * @returns Task result + */ + async getRequestorTaskResult(taskId: string): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + // Use CallToolResultSchema for validation + return await this.client.experimental.tasks.getTaskResult( + taskId, + CallToolResultSchema, + this.getRequestOptions(), + ); + } + + /** + * Cancel a running requestor task (task we created on the server) + * @param taskId Task identifier + * @returns Cancel result + */ + async cancelRequestorTask(taskId: string): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + await this.client.experimental.tasks.cancelTask( + taskId, + this.getRequestOptions(), + ); + + // Dispatch event + this.dispatchTypedEvent("taskCancelled", { taskId }); + } + + /** + * List all requestor tasks with optional pagination (tasks we created on the server) + * @param cursor Optional pagination cursor + * @returns List of tasks with optional next cursor + */ + async listRequestorTasks( + cursor?: string, + ): Promise<{ tasks: Task[]; nextCursor?: string }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + return await this.client.experimental.tasks.listTasks( + cursor, + this.getRequestOptions(), + ); + } + + /** + * Get all pending sampling requests + */ + getPendingSamples(): SamplingCreateMessage[] { + return [...this.pendingSamples]; + } + + /** + * Add a pending sampling request + */ + private addPendingSample(sample: SamplingCreateMessage): void { + this.pendingSamples.push(sample); + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); + this.dispatchTypedEvent("newPendingSample", sample); + } + + /** + * Remove a pending sampling request by ID + */ + removePendingSample(id: string): void { + const index = this.pendingSamples.findIndex((s) => s.id === id); + if (index !== -1) { + this.pendingSamples.splice(index, 1); + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); + } + } + + /** + * Get all pending elicitation requests + */ + getPendingElicitations(): ElicitationCreateMessage[] { + return [...this.pendingElicitations]; + } + + /** + * Add a pending elicitation request + */ + private addPendingElicitation(elicitation: ElicitationCreateMessage): void { + this.pendingElicitations.push(elicitation); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); + this.dispatchTypedEvent("newPendingElicitation", elicitation); + } + + /** + * Remove a pending elicitation request by ID + */ + removePendingElicitation(id: string): void { + const index = this.pendingElicitations.findIndex((e) => e.id === id); + if (index !== -1) { + this.pendingElicitations.splice(index, 1); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); + } + } + + /** + * Get server capabilities + */ + getCapabilities(): ServerCapabilities | undefined { + return this.capabilities; + } + + /** + * Get server info (name, version) + */ + getServerInfo(): Implementation | undefined { + return this.serverInfo; + } + + /** + * Get server instructions + */ + getInstructions(): string | undefined { + return this.instructions; + } + + /** + * Set the logging level for the MCP server + * @param level Logging level to set + * @throws Error if client is not connected or server doesn't support logging + */ + async setLoggingLevel(level: LoggingLevel): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + if (!this.capabilities?.logging) { + throw new Error("Server does not support logging"); + } + await this.client.setLoggingLevel(level, this.getRequestOptions()); + } + + /** + * Fetch a single page of tools without updating the client's internal list. + */ + async listTools( + cursor?: string, + metadata?: Record, + ): Promise<{ tools: Tool[]; nextCursor?: string }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const params: ListToolsRequest["params"] = { + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + ...(cursor ? { cursor } : {}), + }; + const response = await this.client.listTools( + params, + this.getRequestOptions(metadata?.progressToken), + ); + const tools = [...(response.tools || [])]; + return { tools, nextCursor: response.nextCursor }; + } + + /** + * Call a tool. Caller must provide the Tool (e.g. from a state manager). + * @param tool The tool to call (use tool.name for the request) + * @param args Tool arguments + * @param generalMetadata Optional general metadata + * @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general) + * @param taskOptions Optional task options (e.g. ttl) for task-augmented requests + * @returns Tool call response + */ + async callTool( + tool: Tool, + args: Record, + generalMetadata?: Record, + toolSpecificMetadata?: Record, + taskOptions?: { ttl?: number }, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + + if (tool.execution?.taskSupport === "required") { + throw new Error( + `Tool "${tool.name}" requires task support. Use callToolStream() instead of callTool().`, + ); + } + + try { + let convertedArgs: Record = args; + const stringArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + stringArgs[key] = value; + } + } + if (Object.keys(stringArgs).length > 0) { + const convertedStringArgs = convertToolParameters(tool, stringArgs); + convertedArgs = { ...args, ...convertedStringArgs }; + } + + // Merge general metadata with tool-specific metadata + let mergedMetadata: Record | undefined; + if (generalMetadata || toolSpecificMetadata) { + mergedMetadata = { + ...(generalMetadata || {}), + ...(toolSpecificMetadata || {}), + }; + } + + const timestamp = new Date(); + const metadata = + mergedMetadata && Object.keys(mergedMetadata).length > 0 + ? mergedMetadata + : undefined; + + const callParams: { + name: string; + arguments: Record; + _meta?: Record; + task?: { ttl: number }; + } = { + name: tool.name, + arguments: convertedArgs, + _meta: metadata, + }; + if (taskOptions?.ttl != null) { + callParams.task = { ttl: taskOptions.ttl }; + } + + const result = await this.client.callTool( + callParams, + undefined, + this.getRequestOptions(metadata?.progressToken), + ); + + const invocation: ToolCallInvocation = { + toolName: tool.name, + params: args, + result: result as CallToolResult, + timestamp, + success: true, + metadata, + }; + + this.dispatchTypedEvent("toolCallResultChange", { + toolName: tool.name, + params: args, + result: invocation.result, + timestamp, + success: true, + metadata, + }); + + return invocation; + } catch (error) { + // Merge general metadata with tool-specific metadata for error case + let mergedMetadata: Record | undefined; + if (generalMetadata || toolSpecificMetadata) { + mergedMetadata = { + ...(generalMetadata || {}), + ...(toolSpecificMetadata || {}), + }; + } + + const timestamp = new Date(); + const metadata = + mergedMetadata && Object.keys(mergedMetadata).length > 0 + ? mergedMetadata + : undefined; + + const invocation: ToolCallInvocation = { + toolName: tool.name, + params: args, + result: null, + timestamp, + success: false, + error: error instanceof Error ? error.message : String(error), + metadata, + }; + + this.dispatchTypedEvent("toolCallResultChange", { + toolName: tool.name, + params: args, + result: null, + timestamp, + success: false, + error: invocation.error, + metadata, + }); + + throw error; + } + } + + /** + * Call a tool with task support (streaming). + * Caller must provide the Tool (e.g. from a state manager). + * @param tool The tool to call (use tool.name for the request) + * @param args Tool arguments + * @param generalMetadata Optional general metadata + * @param toolSpecificMetadata Optional tool-specific metadata (takes precedence over general) + * @param taskOptions Optional task options (e.g. ttl) for task-augmented requests + * @returns Tool call response + */ + async callToolStream( + tool: Tool, + args: Record, + generalMetadata?: Record, + toolSpecificMetadata?: Record, + taskOptions?: { ttl?: number }, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + try { + let convertedArgs: Record = args; + const stringArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + stringArgs[key] = value; + } + } + if (Object.keys(stringArgs).length > 0) { + const convertedStringArgs = convertToolParameters(tool, stringArgs); + convertedArgs = { ...args, ...convertedStringArgs }; + } + + // Merge general metadata with tool-specific metadata + let mergedMetadata: Record | undefined; + if (generalMetadata || toolSpecificMetadata) { + mergedMetadata = { + ...(generalMetadata || {}), + ...(toolSpecificMetadata || {}), + }; + } + + const timestamp = new Date(); + const metadata = + mergedMetadata && Object.keys(mergedMetadata).length > 0 + ? mergedMetadata + : undefined; + + // Call the streaming API + const streamParams: Record = { + name: tool.name, + arguments: convertedArgs, + }; + if (metadata) { + streamParams._meta = metadata; + } + if (taskOptions?.ttl != null) { + streamParams.task = { ttl: taskOptions.ttl }; + } + const stream = this.client.experimental.tasks.callToolStream( + streamParams as CallToolRequest["params"], + undefined, // Use default CallToolResultSchema + this.getRequestOptions(metadata?.progressToken), + ); + + let finalResult: CallToolResult | undefined; + let taskId: string | undefined; + let error: Error | undefined; + + // Iterate through the async generator + for await (const message of stream) { + switch (message.type) { + case "taskCreated": + taskId = message.task.taskId; + this.dispatchTypedEvent("toolCallTaskUpdated", { + taskId: message.task.taskId, + task: message.task, + }); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId: message.task.taskId, + task: message.task, + }); + break; + + case "taskStatus": + if (!taskId) { + taskId = message.task.taskId; + } + this.dispatchTypedEvent("toolCallTaskUpdated", { + taskId: message.task.taskId, + task: message.task, + }); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId: message.task.taskId, + task: message.task, + }); + break; + + case "result": + finalResult = message.result as CallToolResult; + if (taskId) { + const completedTask: TaskWithOptionalCreatedAt = { + taskId, + ttl: null, + status: "completed", + statusMessage: "Task completed" as string, + lastUpdatedAt: new Date().toISOString(), + }; + this.dispatchTypedEvent("toolCallTaskUpdated", { + taskId, + task: completedTask, + result: finalResult, + }); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId, + task: completedTask, + result: finalResult, + }); + } + break; + + case "error": { + const errorMessage = + message.error.message || "Task execution failed"; + error = new Error(errorMessage); + if (taskId) { + const failedTask: TaskWithOptionalCreatedAt = { + taskId, + ttl: null, + status: "failed", + statusMessage: errorMessage, + lastUpdatedAt: new Date().toISOString(), + }; + this.dispatchTypedEvent("toolCallTaskUpdated", { + taskId, + task: failedTask, + error: message.error, + }); + this.dispatchTypedEvent("requestorTaskUpdated", { + taskId, + task: failedTask, + error: message.error, + }); + } + break; + } + } + } + + // If we got an error, throw it + if (error) { + throw error; + } + + // If we didn't get a result, something went wrong + // This can happen if the task completed but result wasn't in the stream + // Try to get it from the task result endpoint + if (!finalResult && taskId) { + try { + finalResult = await this.client.experimental.tasks.getTaskResult( + taskId, + undefined, + this.getRequestOptions(), // no metadata for fallback + ); + } catch (resultError) { + throw new Error( + `Tool call did not return a result: ${resultError instanceof Error ? resultError.message : String(resultError)}`, + ); + } + } + if (!finalResult) { + throw new Error("Tool call did not return a result"); + } + + const invocation: ToolCallInvocation = { + toolName: tool.name, + params: args, + result: finalResult, + timestamp, + success: true, + metadata, + }; + + this.dispatchTypedEvent("toolCallResultChange", { + toolName: tool.name, + params: args, + result: invocation.result, + timestamp, + success: true, + metadata, + }); + + return invocation; + } catch (error) { + // Merge general metadata with tool-specific metadata for error case + let mergedMetadata: Record | undefined; + if (generalMetadata || toolSpecificMetadata) { + mergedMetadata = { + ...(generalMetadata || {}), + ...(toolSpecificMetadata || {}), + }; + } + + const timestamp = new Date(); + const metadata = + mergedMetadata && Object.keys(mergedMetadata).length > 0 + ? mergedMetadata + : undefined; + + this.dispatchTypedEvent("toolCallResultChange", { + toolName: tool.name, + params: args, + result: null, + timestamp, + success: false, + error: error instanceof Error ? error.message : String(error), + metadata, + }); + + throw error; + } + } + + /** + * List available resources with pagination support (stateless; state managers hold the list). + * @param cursor Optional cursor for pagination + * @param metadata Optional metadata to include in the request + * @returns Object containing resources array and optional nextCursor + */ + async listResources( + cursor?: string, + metadata?: Record, + ): Promise<{ resources: Resource[]; nextCursor?: string }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const params: ListResourcesRequest["params"] = { + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + ...(cursor ? { cursor } : {}), + }; + const response = await this.client.listResources( + params, + this.getRequestOptions(metadata?.progressToken), + ); + return { + resources: response.resources || [], + nextCursor: response.nextCursor, + }; + } + + /** + * Read a resource by URI + * @param uri Resource URI + * @param metadata Optional metadata to include in the request + * @returns Resource content + */ + async readResource( + uri: string, + metadata?: Record, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + const params: ReadResourceRequest["params"] = { + uri, + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + }; + const result = await this.client.readResource( + params, + this.getRequestOptions(metadata?.progressToken), + ); + const invocation: ResourceReadInvocation = { + result, + timestamp: new Date(), + uri, + metadata, + }; + this.dispatchTypedEvent("resourceContentChange", { + uri, + content: invocation, + timestamp: invocation.timestamp, + }); + return invocation; + } + + /** + * Read a resource from a template by expanding the template URI with parameters + * This encapsulates the business logic of template expansion and associates the + * loaded resource with its template in InspectorClient state + * @param templateName The name/ID of the resource template + * @param params Parameters to fill in the template variables + * @param metadata Optional metadata to include in the request + * @returns The resource content along with expanded URI and template name + * @throws Error if template is not found or URI expansion fails + */ + async readResourceFromTemplate( + uriTemplate: string, + params: Record, + metadata?: Record, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + + const uriTemplateString = uriTemplate; + + // Expand the template's uriTemplate using the provided params + let expandedUri: string; + try { + const uriTemplate = new UriTemplate(uriTemplateString); + expandedUri = uriTemplate.expand(params); + } catch (error) { + throw new Error( + `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Always fetch fresh content: Call readResource with expanded URI + const readInvocation = await this.readResource(expandedUri, metadata); + + // Create the template invocation object + const invocation: ResourceTemplateReadInvocation = { + uriTemplate: uriTemplateString, + expandedUri, + result: readInvocation.result, + timestamp: readInvocation.timestamp, + params, + metadata, + }; + + this.dispatchTypedEvent("resourceTemplateContentChange", { + uriTemplate: uriTemplateString, + content: invocation, + params, + timestamp: invocation.timestamp, + }); + + return invocation; + } + + /** + * List resource templates with pagination support (stateless; state managers hold the list). + * @param cursor Optional cursor for pagination + * @param metadata Optional metadata to include in the request + * @returns Object containing resourceTemplates array and optional nextCursor + */ + async listResourceTemplates( + cursor?: string, + metadata?: Record, + ): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const params: ListResourceTemplatesRequest["params"] = { + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + ...(cursor ? { cursor } : {}), + }; + const response = await this.client.listResourceTemplates( + params, + this.getRequestOptions(metadata?.progressToken), + ); + return { + resourceTemplates: response.resourceTemplates || [], + nextCursor: response.nextCursor, + }; + } + + /** + * List available prompts with pagination support + * @param cursor Optional cursor for pagination + * @param metadata Optional metadata to include in the request + * @returns Object containing prompts array and optional nextCursor + */ + async listPrompts( + cursor?: string, + metadata?: Record, + ): Promise<{ prompts: Prompt[]; nextCursor?: string }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const params: ListPromptsRequest["params"] = { + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + ...(cursor ? { cursor } : {}), + }; + const response = await this.client.listPrompts( + params, + this.getRequestOptions(metadata?.progressToken), + ); + return { + prompts: response.prompts || [], + nextCursor: response.nextCursor, + }; + } + + /** + * Get a prompt by name + * @param name Prompt name + * @param args Optional prompt arguments + * @param metadata Optional metadata to include in the request + * @returns Prompt content + */ + async getPrompt( + name: string, + args?: Record, + metadata?: Record, + ): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + // Convert all arguments to strings for prompt arguments + const stringArgs = args ? convertPromptArguments(args) : {}; + + const params: GetPromptRequest["params"] = { + name, + arguments: stringArgs, + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + }; + + const result = await this.client.getPrompt( + params, + this.getRequestOptions(metadata?.progressToken), + ); + + const invocation: PromptGetInvocation = { + result, + timestamp: new Date(), + name, + params: Object.keys(stringArgs).length > 0 ? stringArgs : undefined, + metadata, + }; + + this.dispatchTypedEvent("promptContentChange", { + name, + content: invocation, + timestamp: invocation.timestamp, + }); + + return invocation; + } + + /** + * Request completions for a resource template variable or prompt argument + * @param ref Resource template reference or prompt reference + * @param argumentName Name of the argument/variable to complete + * @param argumentValue Current (partial) value of the argument + * @param context Optional context with other argument values + * @param metadata Optional metadata to include in the request + * @returns Completion result with values array + * @throws Error if client is not connected or request fails (except MethodNotFound) + */ + async getCompletions( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argumentName: string, + argumentValue: string, + context?: Record, + metadata?: Record, + ): Promise<{ values: string[]; total?: number; hasMore?: boolean }> { + if (!this.client) { + return { values: [] }; + } + + try { + const params: CompleteRequest["params"] = { + ref, + argument: { + name: argumentName, + value: argumentValue, + }, + ...(context ? { context: { arguments: context } } : {}), + ...(metadata && Object.keys(metadata).length > 0 + ? { _meta: metadata } + : {}), + }; + + const response = await this.client.complete( + params, + this.getRequestOptions(metadata?.progressToken), + ); + + return { + values: response.completion.values || [], + total: response.completion.total, + hasMore: response.completion.hasMore, + }; + } catch (error) { + // Handle MethodNotFound gracefully (server doesn't support completions) + if ( + (error instanceof McpError && + error.code === ErrorCode.MethodNotFound) || + (error instanceof Error && + (error.message.includes("Method not found") || + error.message.includes("does not support completions"))) + ) { + return { values: [] }; + } + + // Re-throw other errors + throw new Error( + `Failed to get completions: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Fetch server info (capabilities, serverInfo, instructions) from cached initialize response + * This does not send any additional MCP requests - it just reads cached data + * Always called on connect + */ + private async fetchServerInfo(): Promise { + if (!this.client) { + return; + } + + try { + // Get server capabilities (cached from initialize response) + this.capabilities = this.client.getServerCapabilities(); + this.dispatchTypedEvent("capabilitiesChange", this.capabilities); + + // Get server info (name, version) and instructions (cached from initialize response) + this.serverInfo = this.client.getServerVersion(); + this.instructions = this.client.getInstructions(); + this.dispatchTypedEvent("serverInfoChange", this.serverInfo); + if (this.instructions !== undefined) { + this.dispatchTypedEvent("instructionsChange", this.instructions); + } + } catch { + // Ignore errors in fetching server info + } + } + + private dispatchStderrLog(entry: StderrLogEntry): void { + this.dispatchTypedEvent("stderrLog", entry); + } + + private dispatchFetchRequest(entry: FetchRequestEntry): void { + this.logger.info( + { + component: "InspectorClient", + category: entry.category, + fetchRequest: { + url: entry.url, + method: entry.method, + headers: entry.requestHeaders, + body: entry.requestBody ?? "[no body]", + }, + fetchResponse: entry.error + ? { error: entry.error } + : { + status: entry.responseStatus, + statusText: entry.responseStatusText, + headers: entry.responseHeaders, + body: entry.responseBody, + }, + }, + `${entry.category} fetch`, + ); + this.dispatchTypedEvent("fetchRequest", entry); + } + + /** + * Get current session ID (from OAuth state authId) + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** + * Set session ID (typically extracted from OAuth state) + */ + setSessionId(sessionId: string): void { + this.sessionId = sessionId; + } + + /** + * Dispatch saveSession so FetchRequestLogState (or other listeners) can persist. + * Call before OAuth redirect; listeners use sessionStorage with this sessionId. + */ + saveSession(): void { + if (!this.sessionId) return; + this.dispatchTypedEvent("saveSession", { sessionId: this.sessionId }); + } + + /** + * Get current roots + */ + getRoots(): Root[] { + return this.roots !== undefined ? [...this.roots] : []; + } + + /** + * Set roots and notify server if it supports roots/listChanged + * Note: This will enable roots capability if it wasn't already enabled + */ + async setRoots(roots: Root[]): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + + // Enable roots capability if not already enabled + if (this.roots === undefined) { + this.roots = []; + } + this.roots = [...roots]; + this.dispatchTypedEvent("rootsChange", this.roots); + + // Send notification to server - clients can send this notification to any server + // The server doesn't need to advertise support for it + try { + await this.client.notification({ + method: "notifications/roots/list_changed", + }); + } catch (error) { + // Log but don't throw - roots were updated locally even if notification failed + console.error("Failed to send roots/list_changed notification:", error); + } + } + + /** + * Get list of currently subscribed resource URIs + */ + getSubscribedResources(): string[] { + return Array.from(this.subscribedResources); + } + + /** + * Check if a resource is currently subscribed + */ + isSubscribedToResource(uri: string): boolean { + return this.subscribedResources.has(uri); + } + + /** + * Check if the server supports resource subscriptions + */ + supportsResourceSubscriptions(): boolean { + return this.capabilities?.resources?.subscribe === true; + } + + /** + * Subscribe to a resource to receive update notifications + * @param uri - The URI of the resource to subscribe to + * @throws Error if client is not connected or server doesn't support subscriptions + */ + async subscribeToResource(uri: string): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + if (!this.supportsResourceSubscriptions()) { + throw new Error("Server does not support resource subscriptions"); + } + try { + await this.client.subscribeResource({ uri }, this.getRequestOptions()); + this.subscribedResources.add(uri); + this.dispatchTypedEvent( + "resourceSubscriptionsChange", + Array.from(this.subscribedResources), + ); + } catch (error) { + throw new Error( + `Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Unsubscribe from a resource + * @param uri - The URI of the resource to unsubscribe from + * @throws Error if client is not connected + */ + async unsubscribeFromResource(uri: string): Promise { + if (!this.client) { + throw new Error("Client is not connected"); + } + try { + await this.client.unsubscribeResource({ uri }, this.getRequestOptions()); + this.subscribedResources.delete(uri); + this.dispatchTypedEvent( + "resourceSubscriptionsChange", + Array.from(this.subscribedResources), + ); + } catch (error) { + throw new Error( + `Failed to unsubscribe from resource: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // ============================================================================ + // OAuth Support (delegated to oauthManager) + // ============================================================================ + + private ensureOAuthManager(): OAuthManager { + if (!this.oauthManager) { + throw new Error("OAuth not configured. Call setOAuthConfig() first."); + } + return this.oauthManager; + } + + /** + * Get server URL from transport config (full URL including path, for OAuth discovery) + */ + private getServerUrl(): string { + if ( + this.transportConfig.type === "sse" || + this.transportConfig.type === "streamable-http" + ) { + return this.transportConfig.url; + } + // Stdio transports don't have a URL - OAuth not applicable + throw new Error( + "OAuth is only supported for HTTP-based transports (SSE, streamable-http)", + ); + } + + /** + * Set OAuth configuration + */ + setOAuthConfig(config: { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + scope?: string; + }): void { + if (!this.oauthManager) { + throw new Error( + "OAuth config must be set at creation. Pass oauth in constructor.", + ); + } + this.oauthManager.setOAuthConfig(config); + } + + /** + * Initiates OAuth flow using SDK's auth() function (normal mode) + * Can be called directly by user or automatically triggered by 401 errors + */ + async authenticate(): Promise { + return this.ensureOAuthManager().authenticate(); + } + + /** + * Starts guided OAuth flow (step-by-step). Runs only the first step. + * Use proceedOAuthStep() to advance. When oauthStep is "authorization_code", + * set authorizationCode and call proceedOAuthStep() to complete. + */ + async beginGuidedAuth(): Promise { + return this.ensureOAuthManager().beginGuidedAuth(); + } + + /** + * Runs guided OAuth flow to completion. If already started (via beginGuidedAuth), + * continues from current step. Otherwise initializes and runs from the start. + * Returns the authorization URL when user must authorize, or undefined if already complete. + */ + async runGuidedAuth(): Promise { + return this.ensureOAuthManager().runGuidedAuth(); + } + + /** + * Set authorization code for guided OAuth flow. + * Validates that the client is in guided OAuth mode (has active state machine). + * @param authorizationCode The authorization code from the OAuth callback + * @param completeFlow If true, automatically proceed through all remaining steps to completion. + * If false, only set the code and wait for manual progression via proceedOAuthStep(). + * Defaults to false for manual step-by-step control. + * @throws Error if not in guided OAuth flow or not at authorization_code step + */ + async setGuidedAuthorizationCode( + authorizationCode: string, + completeFlow: boolean = false, + ): Promise { + return this.ensureOAuthManager().setGuidedAuthorizationCode( + authorizationCode, + completeFlow, + ); + } + + /** + * Completes OAuth flow with authorization code. + * For guided mode, this calls setGuidedAuthorizationCode(code, true) internally. + * For normal mode, uses SDK auth() directly. + */ + async completeOAuthFlow(authorizationCode: string): Promise { + return this.ensureOAuthManager().completeOAuthFlow(authorizationCode); + } + + /** + * Gets current OAuth tokens (if authorized) + */ + async getOAuthTokens(): Promise { + if (!this.oauthManager) { + return undefined; + } + return this.oauthManager.getOAuthTokens(); + } + + /** + * Clears OAuth tokens and client information + */ + clearOAuthTokens(): void { + this.oauthManager?.clearOAuthTokens(); + } + + /** + * Checks if client is currently OAuth authorized + */ + async isOAuthAuthorized(): Promise { + if (!this.oauthManager) { + return false; + } + return this.oauthManager.isOAuthAuthorized(); + } + + /** + * Get current OAuth state machine state (for guided mode) + */ + getOAuthState(): AuthGuidedState | undefined { + return this.oauthManager?.getOAuthState(); + } + + /** + * Get current OAuth step (for guided mode) + */ + getOAuthStep(): OAuthStep | undefined { + return this.oauthManager?.getOAuthStep(); + } + + /** + * Manually progress to next step in guided OAuth flow + */ + async proceedOAuthStep(): Promise { + return this.ensureOAuthManager().proceedOAuthStep(); + } +} diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts new file mode 100644 index 000000000..7eb1cfe8a --- /dev/null +++ b/core/mcp/inspectorClientEventTarget.ts @@ -0,0 +1,144 @@ +/** + * Type-safe EventTarget for InspectorClient events. + * Extends the generic TypedEventTarget with InspectorClientEventMap; TypedEvent + * is provided as a type alias for use in listener signatures. + */ + +import { + TypedEventTarget, + type TypedEventGeneric, +} from "./typedEventTarget.js"; +import type { + ConnectionStatus, + MessageEntry, + StderrLogEntry, + FetchRequestEntry, + PromptGetInvocation, + ResourceReadInvocation, + ResourceTemplateReadInvocation, +} from "./types.js"; +import type { + Tool, + ServerCapabilities, + Implementation, + Root, + Progress, + ProgressToken, + Task, + CallToolResult, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; +import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; +import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { JsonValue } from "../json/jsonUtils.js"; + +/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ +export type TaskWithOptionalCreatedAt = Omit & { + createdAt?: string; +}; + +/** + * Maps event names to their detail types for CustomEvents + */ +export interface InspectorClientEventMap { + statusChange: ConnectionStatus; + toolsChange: Tool[]; + capabilitiesChange: ServerCapabilities | undefined; + serverInfoChange: Implementation | undefined; + instructionsChange: string | undefined; + message: MessageEntry; + stderrLog: StderrLogEntry; + fetchRequest: FetchRequestEntry; + error: Error; + resourceUpdated: { uri: string }; + progressNotification: Progress & { progressToken?: ProgressToken }; + toolCallResultChange: { + toolName: string; + params: Record; + result: CallToolResult | null; + timestamp: Date; + success: boolean; + error?: string; + metadata?: Record; + }; + resourceContentChange: { + uri: string; + content: ResourceReadInvocation; + timestamp: Date; + }; + resourceTemplateContentChange: { + uriTemplate: string; + content: ResourceTemplateReadInvocation; + params: Record; + timestamp: Date; + }; + promptContentChange: { + name: string; + content: PromptGetInvocation; + timestamp: Date; + }; + pendingSamplesChange: SamplingCreateMessage[]; + newPendingSample: SamplingCreateMessage; + pendingElicitationsChange: ElicitationCreateMessage[]; + newPendingElicitation: ElicitationCreateMessage; + rootsChange: Root[]; + resourceSubscriptionsChange: string[]; + // Task events + /** Fired only from server notification notifications/tasks/status. */ + taskStatusChange: { taskId: string; task: Task }; + /** Fired from callToolStream for each task update (taskCreated, taskStatus, result, error). */ + toolCallTaskUpdated: { + taskId: string; + task: TaskWithOptionalCreatedAt; + result?: CallToolResult; + error?: McpError; + }; + /** Fired from getRequestorTask() and callToolStream (client-origin task updates). */ + requestorTaskUpdated: { + taskId: string; + task: TaskWithOptionalCreatedAt; + result?: CallToolResult; + error?: McpError; + }; + taskCancelled: { taskId: string }; + tasksChange: Task[]; + // Signal events (no payload) + connect: void; + disconnect: void; + // List changed notification events (fired when server sends list_changed notifications) + toolsListChanged: void; + resourcesListChanged: void; + resourceTemplatesListChanged: void; + promptsListChanged: void; + tasksListChanged: void; + // Session persistence (dispatched by client; FetchRequestLogState listens and saves) + saveSession: { sessionId: string }; + // OAuth events + oauthAuthorizationRequired: { + url: URL; + }; + oauthComplete: { + tokens: OAuthTokens; + }; + oauthError: { + error: Error; + }; + oauthStepChange: { + step: OAuthStep; + previousStep: OAuthStep; + state: Partial; + }; +} + +/** + * Type alias for InspectorClient typed events (for listener signatures). + */ +export type TypedEvent = + TypedEventGeneric; + +/** + * Type-safe EventTarget for InspectorClient events. + */ +export class InspectorClientEventTarget extends TypedEventTarget {} diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts new file mode 100644 index 000000000..8c42319b1 --- /dev/null +++ b/core/mcp/messageTrackingTransport.ts @@ -0,0 +1,120 @@ +import type { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { + JSONRPCMessage, + MessageExtraInfo, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + JSONRPCRequest, + JSONRPCNotification, + JSONRPCResultResponse, + JSONRPCErrorResponse, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface MessageTrackingCallbacks { + trackRequest?: (message: JSONRPCRequest) => void; + trackResponse?: ( + message: JSONRPCResultResponse | JSONRPCErrorResponse, + ) => void; + trackNotification?: (message: JSONRPCNotification) => void; +} + +// Transport wrapper that intercepts all messages for tracking +export class MessageTrackingTransport implements Transport { + constructor( + private baseTransport: Transport, + private callbacks: MessageTrackingCallbacks, + ) {} + + async start(): Promise { + return this.baseTransport.start(); + } + + async send( + message: JSONRPCMessage, + options?: TransportSendOptions, + ): Promise { + // Track outgoing requests (only requests have a method and are sent by the client) + if ("method" in message && "id" in message) { + this.callbacks.trackRequest?.(message as JSONRPCRequest); + } + return this.baseTransport.send(message, options); + } + + async close(): Promise { + return this.baseTransport.close(); + } + + get onclose(): (() => void) | undefined { + return this.baseTransport.onclose; + } + + set onclose(handler: (() => void) | undefined) { + this.baseTransport.onclose = handler; + } + + get onerror(): ((error: Error) => void) | undefined { + return this.baseTransport.onerror; + } + + set onerror(handler: ((error: Error) => void) | undefined) { + this.baseTransport.onerror = handler; + } + + get onmessage(): + | ((message: T, extra?: MessageExtraInfo) => void) + | undefined { + return this.baseTransport.onmessage; + } + + set onmessage( + handler: + | (( + message: T, + extra?: MessageExtraInfo, + ) => void) + | undefined, + ) { + if (handler) { + // Wrap the handler to track incoming messages + this.baseTransport.onmessage = ( + message: T, + extra?: MessageExtraInfo, + ) => { + // Track incoming messages + if ( + "id" in message && + message.id !== null && + message.id !== undefined + ) { + // Check if it's a response (has 'result' or 'error' property) + if ("result" in message || "error" in message) { + this.callbacks.trackResponse?.( + message as JSONRPCResultResponse | JSONRPCErrorResponse, + ); + } else if ("method" in message) { + // This is a request coming from the server + this.callbacks.trackRequest?.(message as JSONRPCRequest); + } + } else if ("method" in message) { + // Notification (no ID, has method) + this.callbacks.trackNotification?.(message as JSONRPCNotification); + } + // Call the original handler + handler(message, extra); + }; + } else { + this.baseTransport.onmessage = undefined; + } + } + + get sessionId(): string | undefined { + return this.baseTransport.sessionId; + } + + get setProtocolVersion(): ((version: string) => void) | undefined { + return this.baseTransport.setProtocolVersion; + } +} diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts new file mode 100644 index 000000000..b55ffdd93 --- /dev/null +++ b/core/mcp/node/config.ts @@ -0,0 +1,149 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import type { + MCPConfig, + MCPServerConfig, + StdioServerConfig, + SseServerConfig, + StreamableHttpServerConfig, +} from "../types.js"; + +/** + * Loads and validates an MCP servers configuration file + * @param configPath - Path to the config file (relative to process.cwd() or absolute) + * @returns The parsed MCPConfig + * @throws Error if the file cannot be loaded, parsed, or is invalid + */ +export function loadMcpServersConfig(configPath: string): MCPConfig { + try { + const resolvedPath = resolve(process.cwd(), configPath); + const configContent = readFileSync(resolvedPath, "utf-8"); + const config = JSON.parse(configContent) as MCPConfig; + + if (!config.mcpServers) { + throw new Error("Configuration file must contain an mcpServers element"); + } + + return config; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error loading configuration: ${error.message}`); + } + throw new Error("Error loading configuration: Unknown error"); + } +} + +/** + * Converts CLI arguments to MCPServerConfig format. + * Handles all CLI-specific logic including: + * - Detecting if target is a URL or command + * - Validating transport/URL combinations + * - Auto-detecting transport type from URL path + * - Converting CLI's "http" transport to "streamable-http" + * + * @param args - CLI arguments object with target (URL or command), transport, and headers + * @returns MCPServerConfig suitable for creating an InspectorClient + * @throws Error if arguments are invalid (e.g., args with URLs, stdio with URLs, etc.) + */ +export function argsToMcpServerConfig(args: { + target: string[]; + transport?: "sse" | "stdio" | "http"; + headers?: Record; + env?: Record; +}): MCPServerConfig { + if (args.target.length === 0) { + throw new Error( + "Target is required. Specify a URL or a command to execute.", + ); + } + + const [firstTarget, ...targetArgs] = args.target; + + if (!firstTarget) { + throw new Error("Target is required."); + } + + const isUrl = + firstTarget.startsWith("http://") || firstTarget.startsWith("https://"); + + // Validation: URLs cannot have additional arguments + if (isUrl && targetArgs.length > 0) { + throw new Error("Arguments cannot be passed to a URL-based MCP server."); + } + + // Validation: Transport/URL combinations + if (args.transport) { + if (!isUrl && args.transport !== "stdio") { + throw new Error("Only stdio transport can be used with local commands."); + } + if (isUrl && args.transport === "stdio") { + throw new Error("stdio transport cannot be used with URLs."); + } + } + + // Handle URL-based transports (SSE or streamable-http) + if (isUrl) { + const url = new URL(firstTarget); + + // Determine transport type + let transportType: "sse" | "streamable-http"; + if (args.transport) { + // Convert CLI's "http" to "streamable-http" + if (args.transport === "http") { + transportType = "streamable-http"; + } else if (args.transport === "sse") { + transportType = "sse"; + } else { + // Should not happen due to validation above, but default to SSE + transportType = "sse"; + } + } else { + // Auto-detect from URL path + if (url.pathname.endsWith("/mcp")) { + transportType = "streamable-http"; + } else if (url.pathname.endsWith("/sse")) { + transportType = "sse"; + } else { + // Default to SSE if path doesn't match known patterns + transportType = "sse"; + } + } + + // Create SSE or streamable-http config + if (transportType === "sse") { + const config: SseServerConfig = { + type: "sse", + url: firstTarget, + }; + if (args.headers) { + config.headers = args.headers; + } + return config; + } else { + const config: StreamableHttpServerConfig = { + type: "streamable-http", + url: firstTarget, + }; + if (args.headers) { + config.headers = args.headers; + } + return config; + } + } + + // Handle stdio transport (command-based) + const config: StdioServerConfig = { + type: "stdio", + command: firstTarget, + }; + + if (targetArgs.length > 0) { + config.args = targetArgs; + } + + if (args.env && Object.keys(args.env).length > 0) { + config.env = args.env; + } + + return config; +} diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts new file mode 100644 index 000000000..8e57d0d5e --- /dev/null +++ b/core/mcp/node/index.ts @@ -0,0 +1,2 @@ +export { loadMcpServersConfig, argsToMcpServerConfig } from "./config.js"; +export { createTransportNode } from "./transport.js"; diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts new file mode 100644 index 000000000..4d894e053 --- /dev/null +++ b/core/mcp/node/transport.ts @@ -0,0 +1,112 @@ +import { getServerType } from "../config.js"; +import type { + MCPServerConfig, + StdioServerConfig, + SseServerConfig, + StreamableHttpServerConfig, + CreateTransportOptions, + CreateTransportResult, +} from "../types.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createFetchTracker } from "../fetchTracking.js"; + +/** + * Creates the appropriate transport for an MCP server configuration. + */ +export function createTransportNode( + config: MCPServerConfig, + options: CreateTransportOptions = {}, +): CreateTransportResult { + const serverType = getServerType(config); + const { + fetchFn: optionsFetchFn, + onStderr, + pipeStderr = false, + onFetchRequest, + authProvider, + } = options; + + const baseFetch = optionsFetchFn ?? globalThis.fetch; + + if (serverType === "stdio") { + const stdioConfig = config as StdioServerConfig; + const transport = new StdioClientTransport({ + command: stdioConfig.command, + args: stdioConfig.args || [], + env: stdioConfig.env, + cwd: stdioConfig.cwd, + stderr: pipeStderr ? "pipe" : undefined, + }); + + // Set up stderr listener if requested + if (pipeStderr && transport.stderr && onStderr) { + transport.stderr.on("data", (data: Buffer) => { + const logEntry = data.toString().trim(); + if (logEntry) { + onStderr({ + timestamp: new Date(), + message: logEntry, + }); + } + }); + } + + return { transport: transport }; + } else if (serverType === "sse") { + const sseConfig = config as SseServerConfig; + const url = new URL(sseConfig.url); + + const sseFetch = + (sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch; + const trackedFetch = onFetchRequest + ? createFetchTracker(sseFetch, { trackRequest: onFetchRequest }) + : sseFetch; + + const eventSourceInit: Record = { + ...sseConfig.eventSourceInit, + ...(sseConfig.headers && { headers: sseConfig.headers }), + fetch: trackedFetch, + }; + + const requestInit: RequestInit = { + ...sseConfig.requestInit, + ...(sseConfig.headers && { headers: sseConfig.headers }), + }; + + const postFetch = onFetchRequest + ? createFetchTracker(baseFetch, { trackRequest: onFetchRequest }) + : baseFetch; + + const transport = new SSEClientTransport(url, { + authProvider, + eventSourceInit, + requestInit, + fetch: postFetch, + }); + + return { transport }; + } else { + // streamable-http + const httpConfig = config as StreamableHttpServerConfig; + const url = new URL(httpConfig.url); + + const requestInit: RequestInit = { + ...httpConfig.requestInit, + ...(httpConfig.headers && { headers: httpConfig.headers }), + }; + + const transportFetch = onFetchRequest + ? createFetchTracker(baseFetch, { trackRequest: onFetchRequest }) + : baseFetch; + + const transport = new StreamableHTTPClientTransport(url, { + authProvider, + requestInit, + fetch: transportFetch, + }); + + return { transport }; + } +} diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts new file mode 100644 index 000000000..8e29dca4e --- /dev/null +++ b/core/mcp/oauthManager.ts @@ -0,0 +1,389 @@ +/** + * Internal OAuth sub-manager for InspectorClient. + * Holds OAuth config, state machine, and guided state; orchestrates normal and guided flows. + * Not part of the public API; InspectorClient delegates to this module. + */ + +import { BaseOAuthClientProvider } from "../auth/providers.js"; +import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; +import { EMPTY_GUIDED_STATE } from "../auth/types.js"; +import { OAuthStateMachine } from "../auth/state-machine.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { parseOAuthState } from "../auth/utils.js"; +import type { + InspectorClientOptions, + InspectorClientEnvironment, +} from "./types.js"; + +export type OAuthManagerConfig = NonNullable & + NonNullable; + +export interface OAuthManagerParams { + getServerUrl: () => string; + effectiveAuthFetch: typeof fetch; + getEventTarget: () => EventTarget; + onBeforeOAuthRedirect?: (sessionId: string) => Promise; + initialConfig: OAuthManagerConfig; + dispatchOAuthStepChange: (detail: { + step: OAuthStep; + previousStep: OAuthStep; + state: Partial; + }) => void; + dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void; + dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void; + dispatchOAuthError: (detail: { error: Error }) => void; +} + +/** + * Internal manager for OAuth flow orchestration. + * InspectorClient creates this when oauth is configured and delegates all OAuth methods. + */ +export class OAuthManager { + private oauthConfig: OAuthManagerConfig; + private oauthStateMachine: OAuthStateMachine | null = null; + private oauthState: AuthGuidedState | null = null; + + constructor(private params: OAuthManagerParams) { + this.oauthConfig = { ...params.initialConfig }; + } + + setOAuthConfig(config: { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + scope?: string; + }): void { + this.oauthConfig = { + ...this.oauthConfig, + ...config, + } as OAuthManagerConfig; + } + + private getServerUrl(): string { + return this.params.getServerUrl(); + } + + private async createOAuthProvider( + mode: "normal" | "guided", + ): Promise { + if ( + !this.oauthConfig.storage || + !this.oauthConfig.redirectUrlProvider || + !this.oauthConfig.navigation + ) { + throw new Error( + "OAuth environment components (storage, navigation, redirectUrlProvider) are required.", + ); + } + + const serverUrl = this.getServerUrl(); + const provider = new BaseOAuthClientProvider( + serverUrl, + { + storage: this.oauthConfig.storage, + redirectUrlProvider: this.oauthConfig.redirectUrlProvider, + navigation: this.oauthConfig.navigation, + clientMetadataUrl: this.oauthConfig.clientMetadataUrl, + }, + mode, + ); + + provider.setEventTarget(this.params.getEventTarget()); + + if (this.oauthConfig.scope) { + await provider.saveScope(this.oauthConfig.scope); + } + + if (this.oauthConfig.clientId) { + const clientInfo: OAuthClientInformation = { + client_id: this.oauthConfig.clientId, + ...(this.oauthConfig.clientSecret && { + client_secret: this.oauthConfig.clientSecret, + }), + }; + await provider.savePreregisteredClientInformation(clientInfo); + } + + return provider; + } + + async authenticate(): Promise { + const provider = await this.createOAuthProvider("normal"); + const serverUrl = this.getServerUrl(); + + provider.clearCapturedAuthUrl(); + + const result = await auth(provider, { + serverUrl, + scope: provider.scope, + fetchFn: this.params.effectiveAuthFetch, + }); + + if (result === "AUTHORIZED") { + throw new Error( + "Unexpected: auth() returned AUTHORIZED without authorization code", + ); + } + + const capturedUrl = provider.getCapturedAuthUrl(); + if (!capturedUrl) { + throw new Error("Failed to capture authorization URL"); + } + + const stateParam = capturedUrl.searchParams.get("state"); + if (stateParam && this.params.onBeforeOAuthRedirect) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + await this.params.onBeforeOAuthRedirect(parsedState.authId); + } + } + + const clientInfo = await provider.clientInformation(); + this.oauthState = { + ...EMPTY_GUIDED_STATE, + authType: "normal", + oauthStep: "authorization_code", + authorizationUrl: capturedUrl, + oauthClientInfo: clientInfo ?? null, + }; + return capturedUrl; + } + + async beginGuidedAuth(): Promise { + const provider = await this.createOAuthProvider("guided"); + const serverUrl = this.getServerUrl(); + + this.oauthState = { ...EMPTY_GUIDED_STATE }; + if (this.oauthConfig.clientId) { + this.oauthState.oauthClientInfo = { + client_id: this.oauthConfig.clientId, + ...(this.oauthConfig.clientSecret && { + client_secret: this.oauthConfig.clientSecret, + }), + }; + } + this.oauthStateMachine = new OAuthStateMachine( + serverUrl, + provider, + (updates) => { + const state = this.oauthState; + if (!state) throw new Error("OAuth state not initialized"); + const previousStep = state.oauthStep; + this.oauthState = { ...state, ...updates }; + if (updates.oauthStep === "complete") { + this.oauthState!.completedAt = Date.now(); + } + const step = updates.oauthStep ?? previousStep; + this.params.dispatchOAuthStepChange({ + step, + previousStep, + state: updates, + }); + }, + this.params.effectiveAuthFetch, + ); + + await this.oauthStateMachine.executeStep(this.oauthState); + } + + async runGuidedAuth(): Promise { + if (!this.oauthStateMachine || !this.oauthState) { + await this.beginGuidedAuth(); + } + + const machine = this.oauthStateMachine; + if (!machine) { + throw new Error("Guided auth failed to initialize state"); + } + + while (true) { + const state = this.oauthState; + if (!state) { + throw new Error("Guided auth failed to initialize state"); + } + if ( + state.oauthStep === "authorization_code" || + state.oauthStep === "complete" + ) { + break; + } + await machine.executeStep(state); + } + + const state = this.oauthState; + if (state?.oauthStep === "complete") { + return undefined; + } + if (!state?.authorizationUrl) { + throw new Error("Failed to generate authorization URL"); + } + + const stateParam = state.authorizationUrl.searchParams.get("state"); + if (stateParam && this.params.onBeforeOAuthRedirect) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + await this.params.onBeforeOAuthRedirect(parsedState.authId); + } + } + + this.params.dispatchOAuthAuthorizationRequired({ + url: state.authorizationUrl, + }); + + return state.authorizationUrl; + } + + async setGuidedAuthorizationCode( + authorizationCode: string, + completeFlow: boolean = false, + ): Promise { + if (!this.oauthStateMachine || !this.oauthState) { + throw new Error( + "Not in guided OAuth flow. Call beginGuidedAuth() first.", + ); + } + const currentStep = this.oauthState.oauthStep; + if (currentStep !== "authorization_code") { + throw new Error( + `Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`, + ); + } + + this.oauthState.authorizationCode = authorizationCode; + + if (completeFlow) { + await this.oauthStateMachine.executeStep(this.oauthState); + let step: OAuthStep = this.oauthState.oauthStep; + while (step !== "complete") { + await this.oauthStateMachine.executeStep(this.oauthState); + step = this.oauthState.oauthStep; + } + + if (!this.oauthState.oauthTokens) { + throw new Error("Failed to exchange authorization code for tokens"); + } + + this.params.dispatchOAuthComplete({ + tokens: this.oauthState.oauthTokens, + }); + } else { + this.params.dispatchOAuthStepChange({ + step: this.oauthState.oauthStep, + previousStep: this.oauthState.oauthStep, + state: { authorizationCode }, + }); + } + } + + async completeOAuthFlow(authorizationCode: string): Promise { + try { + if (this.oauthStateMachine && this.oauthState) { + await this.setGuidedAuthorizationCode(authorizationCode, true); + } else { + const provider = await this.createOAuthProvider("normal"); + const serverUrl = this.getServerUrl(); + + const result = await auth(provider, { + serverUrl, + authorizationCode, + fetchFn: this.params.effectiveAuthFetch, + }); + + if (result !== "AUTHORIZED") { + throw new Error( + `Expected AUTHORIZED after providing authorization code, got: ${result}`, + ); + } + + const tokens = await provider.tokens(); + if (!tokens) { + throw new Error("Failed to retrieve tokens after authorization"); + } + + const clientInfo = await provider.clientInformation(); + const completedAt = Date.now(); + this.oauthState = this.oauthState + ? { + ...this.oauthState, + oauthStep: "complete", + oauthTokens: tokens, + oauthClientInfo: clientInfo ?? null, + completedAt, + } + : { + ...EMPTY_GUIDED_STATE, + authType: "normal", + oauthStep: "complete", + oauthTokens: tokens, + oauthClientInfo: clientInfo ?? null, + completedAt, + }; + + this.params.dispatchOAuthComplete({ tokens }); + } + } catch (error) { + this.params.dispatchOAuthError({ + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } + + async getOAuthTokens(): Promise { + if (this.oauthState?.oauthTokens) { + return this.oauthState.oauthTokens; + } + + const provider = await this.createOAuthProvider("normal"); + try { + return await provider.tokens(); + } catch { + return undefined; + } + } + + clearOAuthTokens(): void { + if (!this.oauthConfig?.storage) { + return; + } + + const serverUrl = this.getServerUrl(); + this.oauthConfig.storage.clear(serverUrl); + + this.oauthState = null; + this.oauthStateMachine = null; + } + + async isOAuthAuthorized(): Promise { + const tokens = await this.getOAuthTokens(); + return tokens !== undefined; + } + + getOAuthState(): AuthGuidedState | undefined { + return this.oauthState ? { ...this.oauthState } : undefined; + } + + getOAuthStep(): OAuthStep | undefined { + return this.oauthState?.oauthStep; + } + + async proceedOAuthStep(): Promise { + if (!this.oauthStateMachine || !this.oauthState) { + throw new Error( + "Not in guided OAuth flow. Call authenticateGuided() first.", + ); + } + + await this.oauthStateMachine.executeStep(this.oauthState); + } + + /** + * Create an OAuth provider for transport auth (connect()). + * Used only when isHttpOAuthConfig() is true. + */ + async createOAuthProviderForTransport(): Promise { + return this.createOAuthProvider("normal"); + } +} diff --git a/core/mcp/remote/constants.ts b/core/mcp/remote/constants.ts new file mode 100644 index 000000000..05dbb0137 --- /dev/null +++ b/core/mcp/remote/constants.ts @@ -0,0 +1,14 @@ +/** + * Environment variable names for the remote server. + * This is shared between browser and Node.js code, so it's in the base remote directory. + */ +/** Legacy env var name; prefer AUTH_TOKEN. Honored when AUTH_TOKEN is not set. */ +export const LEGACY_AUTH_TOKEN_ENV = "MCP_PROXY_AUTH_TOKEN"; + +export const API_SERVER_ENV_VARS = { + /** + * Auth token for authenticating requests to the remote API server. + * Used by the x-mcp-remote-auth header (or Authorization header if changed). + */ + AUTH_TOKEN: "MCP_INSPECTOR_API_TOKEN", +} as const; diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts new file mode 100644 index 000000000..2633cffe0 --- /dev/null +++ b/core/mcp/remote/createRemoteFetch.ts @@ -0,0 +1,139 @@ +/** + * Creates a fetch implementation that POSTs requests to the remote /api/fetch endpoint. + * Use in the browser to bypass CORS for OAuth and MCP HTTP requests. + */ + +export interface RemoteFetchOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + + /** Base fetch to use for the POST to the remote (default: globalThis.fetch) */ + fetchFn?: typeof fetch; +} + +/** + * Serialize request for the remote. Handles URLSearchParams body for OAuth token exchange. + */ +async function serializeRequest( + input: RequestInfo | URL, + init?: RequestInit, +): Promise<{ + url: string; + method: string; + headers: Record; + body?: string; +}> { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const method = + init?.method ?? + (typeof input === "object" && "method" in input + ? (input as Request).method + : "GET"); + + const headers: Record = {}; + if (input instanceof Request) { + input.headers.forEach((v, k) => { + headers[k] = v; + }); + } + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => { + headers[k] = v; + }); + } + + let body: string | undefined; + if (init?.body !== undefined && init?.body !== null) { + if (typeof init.body === "string") { + body = init.body; + } else if (init.body instanceof URLSearchParams) { + body = init.body.toString(); + } else if (init.body instanceof FormData) { + const params = new URLSearchParams(); + for (const [key, value] of init.body.entries()) { + if (typeof value === "string") { + params.set(key, value); + } + } + body = params.toString(); + } else { + body = String(init.body); + } + } else if (input instanceof Request && input.body) { + const cloned = input.clone(); + body = await cloned.text(); + } + + return { url, method, headers, body }; +} + +/** + * Deserialize remote response into a Response object. + */ +function deserializeResponse(data: { + ok: boolean; + status: number; + statusText: string; + headers: Record; + body?: string; +}): Response { + return new Response(data.body ?? null, { + status: data.status, + statusText: data.statusText, + headers: new Headers(data.headers ?? {}), + }); +} + +/** + * Returns a fetch function that forwards requests to the remote /api/fetch endpoint. + * The remote server performs the actual HTTP request in Node (no CORS). + */ +export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch { + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const { url, method, headers, body } = await serializeRequest(input, init); + + const reqHeaders: Record = { + "Content-Type": "application/json", + ...headers, + }; + if (options.authToken) { + reqHeaders["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/fetch`, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify({ url, method, headers, body }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Remote fetch failed (${res.status}): ${text}`); + } + + const data = (await res.json()) as { + ok: boolean; + status: number; + statusText: string; + headers: Record; + body?: string; + }; + + return deserializeResponse(data); + }; +} diff --git a/core/mcp/remote/createRemoteLogger.ts b/core/mcp/remote/createRemoteLogger.ts new file mode 100644 index 000000000..e4aae396e --- /dev/null +++ b/core/mcp/remote/createRemoteLogger.ts @@ -0,0 +1,62 @@ +/** + * Creates a pino logger that POSTs log events to the remote /api/log endpoint + * via browser.transmit. Use in the browser when InspectorClient needs logging— + * logs are written server-side to the same file logger as Node mode. + * + * Uses pino/browser so transmit works in both Node (tests) and browser. + */ + +// @ts-expect-error - pino/browser.js exists but TypeScript doesn't have types for the .js extension +// Node.js ESM requires explicit .js extension, and pino exports browser.js +import pino from "pino/browser.js"; +import type { Logger, LogEvent } from "pino"; + +export interface RemoteLoggerOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + + /** Fetch function to use (default: globalThis.fetch) */ + fetchFn?: typeof fetch; + + /** Minimum level to send (default: 'info') */ + level?: string; +} + +/** + * Creates a pino logger that transmits log events to the remote /api/log endpoint. + * Returns a real pino.Logger; suitable for InspectorClient's logger option. + */ +export function createRemoteLogger(options: RemoteLoggerOptions): Logger { + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + const level = options.level ?? "info"; + + return pino({ + level, + browser: { + write: () => {}, + transmit: { + level, + send: (_level: unknown, logEvent: LogEvent) => { + const headers: Record = { + "Content-Type": "application/json", + }; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + fetchFn(`${baseUrl}/api/log`, { + method: "POST", + headers, + body: JSON.stringify(logEvent), + }).catch(() => { + // Silently ignore log delivery failures + }); + }, + }, + }, + }); +} diff --git a/core/mcp/remote/createRemoteTransport.ts b/core/mcp/remote/createRemoteTransport.ts new file mode 100644 index 000000000..a4842b99f --- /dev/null +++ b/core/mcp/remote/createRemoteTransport.ts @@ -0,0 +1,66 @@ +/** + * Factory for createRemoteTransport - returns a CreateTransport that uses the remote server. + */ + +import type { + MCPServerConfig, + CreateTransport, + CreateTransportOptions, + CreateTransportResult, +} from "../types.js"; +import { RemoteClientTransport } from "./remoteClientTransport.js"; + +export interface RemoteTransportFactoryOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + + /** Optional fetch implementation (for proxy or testing) */ + fetchFn?: typeof fetch; +} + +/** + * Creates a CreateTransport that produces RemoteClientTransport instances + * connecting to the given remote server. + * + * @example + * import { API_SERVER_ENV_VARS } from '@modelcontextprotocol/inspector-core/mcp/remote'; + * const createTransport = createRemoteTransport({ + * baseUrl: 'http://localhost:3000', + * authToken: process.env[API_SERVER_ENV_VARS.AUTH_TOKEN], + * }); + * const inspector = new InspectorClient(config, { + * environment: { + * transport: createTransport, + * }, + * ... + * }); + */ +export function createRemoteTransport( + options: RemoteTransportFactoryOptions, +): CreateTransport { + return ( + config: MCPServerConfig, + transportOptions: CreateTransportOptions = {}, + ): CreateTransportResult => { + // Use only the factory's fetchFn, not InspectorClient's. The transport's HTTP + // (connect, GET events, send, disconnect) must support streaming (GET /api/mcp/events + // is SSE). A remoted fetch (e.g. createRemoteFetch) buffers responses and cannot + // stream. So we ignore transportOptions.fetchFn here; auth can still use a + // remoted fetch via InspectorClient's fetchFn (effectiveAuthFetch). + const transport = new RemoteClientTransport( + { + baseUrl: options.baseUrl, + authToken: options.authToken, + fetchFn: options.fetchFn, + onStderr: transportOptions.onStderr, + onFetchRequest: transportOptions.onFetchRequest, + authProvider: transportOptions.authProvider, + }, + config, + ); + return { transport }; + }; +} diff --git a/core/mcp/remote/index.ts b/core/mcp/remote/index.ts new file mode 100644 index 000000000..959acece2 --- /dev/null +++ b/core/mcp/remote/index.ts @@ -0,0 +1,31 @@ +/** + * Remote transport client - pure TypeScript, runs in browser, Deno, or Node. + * Talks to the remote server for MCP connections when direct transport is not available. + */ + +export { + RemoteClientTransport, + type RemoteTransportOptions, +} from "./remoteClientTransport.js"; +export { + createRemoteTransport, + type RemoteTransportFactoryOptions, +} from "./createRemoteTransport.js"; +export { + createRemoteFetch, + type RemoteFetchOptions, +} from "./createRemoteFetch.js"; +export { + createRemoteLogger, + type RemoteLoggerOptions, +} from "./createRemoteLogger.js"; +export { + RemoteInspectorClientStorage, + type RemoteInspectorClientStorageOptions, +} from "./sessionStorage.js"; +export type { + RemoteConnectRequest, + RemoteConnectResponse, + RemoteEvent, +} from "./types.js"; +export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "./constants.js"; diff --git a/core/mcp/remote/node/index.ts b/core/mcp/remote/node/index.ts new file mode 100644 index 000000000..0bff99cd4 --- /dev/null +++ b/core/mcp/remote/node/index.ts @@ -0,0 +1,11 @@ +/** + * Remote server (Node) - Hono app for /api/mcp/*, /api/fetch, /api/log. + */ + +export { + createRemoteApp, + type RemoteServerOptions, + type CreateRemoteAppResult, +} from "./server.js"; +// Re-export constants from base remote directory (browser-safe) +export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "../constants.js"; diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts new file mode 100644 index 000000000..227662568 --- /dev/null +++ b/core/mcp/remote/node/remote-session.ts @@ -0,0 +1,107 @@ +/** + * Remote session - holds a transport and event queue for a remote client. + */ + +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { FetchRequestEntryBase } from "../../types.js"; +import type { RemoteEvent } from "../types.js"; + +export interface SessionEvent { + type: RemoteEvent["type"]; + data: unknown; +} + +export class RemoteSession { + public readonly sessionId: string; + public transport!: Transport; + private eventQueue: SessionEvent[] = []; + private eventConsumer: ((event: SessionEvent) => void) | null = null; + private transportDead: boolean = false; + private transportError: string | null = null; + + constructor(sessionId: string) { + this.sessionId = sessionId; + } + + setTransport(transport: Transport): void { + this.transport = transport; + } + + setEventConsumer(consumer: (event: SessionEvent) => void): void { + this.eventConsumer = consumer; + // Flush queued events + while (this.eventQueue.length > 0) { + const ev = this.eventQueue.shift()!; + consumer(ev); + } + } + + clearEventConsumer(): boolean { + this.eventConsumer = null; + // If transport is dead and no client connected, signal to cleanup + return this.transportDead; + } + + markTransportDead(error: string): void { + this.transportDead = true; + this.transportError = error; + // Send error event if client is connected + if (this.eventConsumer) { + this.pushEvent({ + type: "transport_error", + data: { + error, + code: -32000, // MCP error code for connection closed + }, + }); + } + } + + isTransportDead(): boolean { + return this.transportDead; + } + + getTransportError(): string | null { + return this.transportError; + } + + hasEventConsumer(): boolean { + return this.eventConsumer !== null; + } + + pushEvent(event: SessionEvent): void { + if (this.eventConsumer) { + this.eventConsumer(event); + } else { + this.eventQueue.push(event); + } + } + + onMessage(message: JSONRPCMessage): void { + this.pushEvent({ type: "message", data: message }); + } + + onFetchRequest(entry: FetchRequestEntryBase): void { + this.pushEvent({ + type: "fetch_request", + data: { + ...entry, + timestamp: + entry.timestamp instanceof Date + ? entry.timestamp.toISOString() + : entry.timestamp, + }, + }); + } + + onStderr(entry: { timestamp: Date; message: string }): void { + this.pushEvent({ + type: "stdio_log", + data: { + timestamp: entry.timestamp.toISOString(), + message: entry.message, + }, + }); + } +} diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts new file mode 100644 index 000000000..1646493f9 --- /dev/null +++ b/core/mcp/remote/node/server.ts @@ -0,0 +1,703 @@ +/** + * Hono-based remote server for MCP transports. + * Hosts /api/config, /api/mcp/connect, send, events, disconnect, /api/fetch, /api/log, /api/storage/:storeId. + */ + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import type pino from "pino"; +import { + getDefaultStorageDir, + getStoreFilePath, + validateStoreId, + readStoreFile, + writeStoreFile, + deleteStoreFile, + parseStore, + serializeStore, +} from "../../../storage/store-io.js"; +import type { LogEvent } from "pino"; +import { Hono } from "hono"; +import type { Context, Next } from "hono"; +import { streamSSE } from "hono/streaming"; +import { createTransportNode } from "../../node/transport.js"; +import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js"; +import type { MCPServerConfig } from "../../types.js"; +import { RemoteSession } from "./remote-session.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { API_SERVER_ENV_VARS } from "../constants.js"; + +export interface RemoteServerOptions { + /** Optional auth token. If not provided, uses API_SERVER_ENV_VARS.AUTH_TOKEN env var or generates one. Ignored when dangerouslyOmitAuth is true. */ + authToken?: string; + + /** + * When true, do not require x-mcp-remote-auth on API routes. + * Origin validation (allowedOrigins) still applies. + * Set via DANGEROUSLY_OMIT_AUTH env var; not recommended for any exposed deployment. + */ + dangerouslyOmitAuth?: boolean; + + /** Optional: validate Origin header against allowed origins (for CORS) */ + allowedOrigins?: string[]; + + /** Optional pino file logger. When set, /api/log forwards received events to it. */ + logger?: pino.Logger; + + /** Optional storage directory for /api/storage/:storeId. Default: ~/.mcp-inspector/storage */ + storageDir?: string; + + /** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */ + sandboxUrl?: string; +} + +export interface CreateRemoteAppResult { + /** The Hono app */ + app: Hono; + /** The auth token (from options, env var, or generated). Returned so caller can embed in client. */ + authToken: string; +} + +/** + * Hono middleware for origin validation (CORS and DNS rebinding protection). + * Validates Origin header against allowedOrigins if provided. + */ +function createOriginMiddleware(allowedOrigins?: string[]) { + return async (c: Context, next: Next) => { + // If no allowedOrigins configured, skip validation (allow all) + if (!allowedOrigins || allowedOrigins.length === 0) { + await next(); + return; + } + + const origin = c.req.header("origin"); + + // Handle CORS preflight requests + if (c.req.method === "OPTIONS") { + if (origin && allowedOrigins.includes(origin)) { + c.header("Access-Control-Allow-Origin", origin); + c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + c.header( + "Access-Control-Allow-Headers", + "Content-Type, x-mcp-remote-auth", + ); + c.header("Access-Control-Max-Age", "86400"); // 24 hours + return c.body(null, 204); + } + // Invalid origin for preflight - return 403 + return c.json( + { + error: "Forbidden", + message: + "Invalid origin. Request blocked to prevent DNS rebinding attacks.", + }, + 403, + ); + } + + // For actual requests, validate origin if present + if (origin) { + if (!allowedOrigins.includes(origin)) { + return c.json( + { + error: "Forbidden", + message: + "Invalid origin. Request blocked to prevent DNS rebinding attacks. Configure allowed origins via allowedOrigins option.", + }, + 403, + ); + } + // Set CORS header for allowed origin + c.header("Access-Control-Allow-Origin", origin); + } + // If no origin header (same-origin or non-browser client), allow request + + await next(); + }; +} + +/** + * Hono middleware for auth token validation. + * Expects Bearer token format: x-mcp-remote-auth: Bearer + */ +function createAuthMiddleware(authToken: string) { + return async (c: Context, next: Next) => { + const authHeader = c.req.header("x-mcp-remote-auth"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json( + { + error: "Unauthorized", + message: + "Authentication required. Use the x-mcp-remote-auth header with Bearer token.", + }, + 401, + ); + } + + const providedToken = authHeader.substring(7); // Remove 'Bearer ' prefix + const expectedToken = authToken; + + // Convert to buffers for timing-safe comparison + const providedBuffer = Buffer.from(providedToken); + const expectedBuffer = Buffer.from(expectedToken); + + // Check length first to prevent timing attacks + if (providedBuffer.length !== expectedBuffer.length) { + return c.json( + { + error: "Unauthorized", + message: + "Authentication required. Use the x-mcp-remote-auth header with Bearer token.", + }, + 401, + ); + } + + // Perform timing-safe comparison + if (!timingSafeEqual(providedBuffer, expectedBuffer)) { + return c.json( + { + error: "Unauthorized", + message: + "Authentication required. Use the x-mcp-remote-auth header with Bearer token.", + }, + 401, + ); + } + + await next(); + }; +} + +/** + * Build initial config object from process.env for GET /api/config. + * Same shape as previously injected via __INITIAL_CONFIG__. + */ +function buildInitialConfigFromEnv(): { + defaultCommand?: string; + defaultArgs?: string[]; + defaultTransport?: string; + defaultServerUrl?: string; + defaultHeaders?: Record; + defaultCwd?: string; + defaultEnvironment: Record; +} { + const defaultEnvKeys = + process.platform === "win32" + ? [ + "APPDATA", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "PATH", + "PROCESSOR_ARCHITECTURE", + "SYSTEMDRIVE", + "SYSTEMROOT", + "TEMP", + "USERNAME", + "USERPROFILE", + "PROGRAMFILES", + ] + : ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; + + const defaultEnvironment: Record = {}; + for (const key of defaultEnvKeys) { + const value = process.env[key]; + if (value && !value.startsWith("()")) { + defaultEnvironment[key] = value; + } + } + if (process.env.MCP_ENV_VARS) { + try { + Object.assign( + defaultEnvironment, + JSON.parse(process.env.MCP_ENV_VARS) as Record, + ); + } catch { + // Ignore invalid MCP_ENV_VARS + } + } + + return { + ...(process.env.MCP_INITIAL_COMMAND + ? { defaultCommand: process.env.MCP_INITIAL_COMMAND } + : {}), + ...(process.env.MCP_INITIAL_ARGS + ? { defaultArgs: process.env.MCP_INITIAL_ARGS.split(" ") } + : {}), + ...(process.env.MCP_INITIAL_TRANSPORT + ? { defaultTransport: process.env.MCP_INITIAL_TRANSPORT } + : {}), + ...(process.env.MCP_INITIAL_SERVER_URL + ? { defaultServerUrl: process.env.MCP_INITIAL_SERVER_URL } + : {}), + ...(process.env.MCP_INITIAL_HEADERS + ? (() => { + try { + const parsed = JSON.parse( + process.env.MCP_INITIAL_HEADERS, + ) as Record; + return Object.keys(parsed).length > 0 + ? { defaultHeaders: parsed } + : {}; + } catch { + return {}; + } + })() + : {}), + ...(process.env.MCP_INITIAL_CWD + ? { defaultCwd: process.env.MCP_INITIAL_CWD } + : {}), + defaultEnvironment, + }; +} + +/** + * Simple OAuth client provider that just returns tokens. + * Used by remote server to inject Bearer tokens into transport requests. + */ +function createTokenAuthProvider( + tokens: RemoteConnectRequest["oauthTokens"], +): OAuthClientProvider | undefined { + if (!tokens) return undefined; + + return { + async tokens(): Promise { + return tokens as OAuthTokens; + }, + // Other methods not needed for transport Bearer token injection + async clientInformation() { + return undefined; + }, + async saveTokens() { + // No-op + }, + codeVerifier() { + return undefined; + }, + async saveCodeVerifier() { + // No-op + }, + clear() { + // No-op + }, + redirectToAuthorization() { + // No-op + }, + state() { + return ""; + }, + } as unknown as OAuthClientProvider; +} + +function forwardLogEvent( + logger: pino.Logger, + logEvent: Partial, +): void { + const levelLabel = (logEvent?.level?.label ?? "info").toLowerCase(); + const method = (logger as unknown as Record)[levelLabel]; + if (typeof method !== "function") return; + + const bindings = Object.assign( + {}, + ...(Array.isArray(logEvent.bindings) ? logEvent.bindings : []), + ); + const messages = Array.isArray(logEvent.messages) ? logEvent.messages : []; + + if (messages.length === 0) { + (method as (obj: object) => void).call(logger, bindings); + return; + } + + const first = messages[0]; + if (typeof first === "object" && first !== null && !Array.isArray(first)) { + const obj = { ...bindings, ...(first as Record) }; + const msg = messages[1]; + const args = messages.slice(2); + (method as (obj: object, msg?: unknown, ...args: unknown[]) => void).call( + logger, + obj, + msg, + ...args, + ); + } else { + const msg = messages[0]; + const args = messages.slice(1); + (method as (obj: object, msg?: unknown, ...args: unknown[]) => void).call( + logger, + bindings, + msg, + ...args, + ); + } +} + +export function createRemoteApp( + options: RemoteServerOptions = {}, +): CreateRemoteAppResult { + const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth; + + // Determine auth token when auth is enabled: options > env var > generate + const authToken = dangerouslyOmitAuth + ? "" + : options.authToken || + process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || + randomBytes(32).toString("hex"); + + const app = new Hono(); + const sessions = new Map(); + const { logger: fileLogger, allowedOrigins } = options; + const storageDir = options.storageDir ?? getDefaultStorageDir(); + + // Apply origin validation middleware first (before auth) + // This prevents DNS rebinding attacks by validating Origin header + app.use("*", createOriginMiddleware(allowedOrigins)); + + // Apply auth middleware unless dangerously omitted + if (!dangerouslyOmitAuth) { + app.use("*", createAuthMiddleware(authToken)); + } + + app.get("/api/config", (c) => { + const initialConfig = buildInitialConfigFromEnv(); + const payload = options.sandboxUrl + ? { ...initialConfig, sandboxUrl: options.sandboxUrl } + : initialConfig; + return c.json(payload); + }); + + app.post("/api/mcp/connect", async (c) => { + let body: RemoteConnectRequest; + try { + body = (await c.req.json()) as RemoteConnectRequest; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const config = body.config as MCPServerConfig; + if (!config) { + return c.json({ error: "Missing config" }, 400); + } + + const sessionId = crypto.randomUUID(); + const session = new RemoteSession(sessionId); + + let transport: Awaited>["transport"]; + try { + // Create authProvider from tokens if provided + const authProvider = createTokenAuthProvider(body.oauthTokens); + + const result = createTransportNode(config, { + pipeStderr: true, + onStderr: (entry) => session.onStderr(entry), + onFetchRequest: (entry) => session.onFetchRequest(entry), + authProvider, + }); + transport = result.transport; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: `Failed to create transport: ${msg}` }, 500); + } + + session.setTransport(transport); + transport.onmessage = (msg) => session.onMessage(msg); + + // Track if transport closes/errors during start - this matches local behavior + // If transport.start() throws, we catch it. If it resolves but transport closes immediately, + // we detect that too (process failure after spawn). + let transportFailed = false; + let transportError: string | null = null; + + const originalOnclose = transport.onclose; + const originalOnerror = transport.onerror; + + // Set up error handlers BEFORE calling start() so we catch failures during start + transport.onerror = (err) => { + transportFailed = true; + transportError = err instanceof Error ? err.message : String(err); + originalOnerror?.(err); + }; + + transport.onclose = () => { + const session = sessions.get(sessionId); + if (session) { + // Mark transport as dead but don't delete session yet + // We'll notify client via SSE and cleanup when client disconnects + const errorMsg = + transportError || "Transport closed - process may have exited"; + session.markTransportDead(errorMsg); + // If no client connected, can cleanup immediately + if (!session.hasEventConsumer()) { + sessions.delete(sessionId); + } + } else { + // Session not created yet - failed during start + transportFailed = true; + transportError = + transportError || + "Transport closed during start - process may have failed"; + } + originalOnclose?.(); + }; + + try { + // transport.start() should throw if process fails to start + // If it resolves, the process should be running + await transport.start(); + + // Check if transport failed during start (onerror/onclose fired synchronously) + if (transportFailed) { + const errorMsg = transportError || "Transport failed during start"; + return c.json({ error: `Failed to start transport: ${errorMsg}` }, 500); + } + } catch (err) { + // transport.start() threw - this is the expected failure path + const msg = err instanceof Error ? err.message : String(err); + // Preserve 401 only when the transport/SDK reports it (no message guessing) + const status = + (err as { code?: number; status?: number }).code ?? + (err as { code?: number; status?: number }).status; + const is401 = status === 401; + return c.json( + { error: `Failed to start transport: ${msg}` }, + is401 ? 401 : 500, + ); + } + + // Transport started successfully - add to sessions + sessions.set(sessionId, session); + + return c.json({ sessionId }); + }); + + app.post("/api/mcp/send", async (c) => { + let body: RemoteSendRequest & { sessionId?: string }; + try { + body = (await c.req.json()) as RemoteSendRequest & { sessionId?: string }; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const { sessionId, message, relatedRequestId } = body; + if (!sessionId || !message) { + return c.json({ error: "Missing sessionId or message" }, 400); + } + + const session = sessions.get(sessionId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + // Check if transport is dead - return error immediately (matches local behavior) + if (session.isTransportDead()) { + const errorMsg = session.getTransportError() || "Transport closed"; + return c.json({ error: errorMsg }, 500); + } + + try { + await session.transport.send(message, { + relatedRequestId: relatedRequestId as string | number | undefined, + }); + return c.json({ ok: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Preserve 401 only when the transport/SDK reports it (no message guessing) + const status = + (err as { code?: number; status?: number }).code ?? + (err as { code?: number; status?: number }).status; + const is401 = status === 401; + return c.json({ error: msg }, is401 ? 401 : 500); + } + }); + + app.get("/api/mcp/events", async (c) => { + const sessionId = c.req.query("sessionId"); + if (!sessionId) { + return c.json({ error: "Missing sessionId query" }, 400); + } + + const session = sessions.get(sessionId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + return streamSSE(c, async (stream) => { + session.setEventConsumer((event) => { + const data = JSON.stringify(event); + void stream.writeSSE({ + event: event.type, + data, + }); + }); + + stream.onAbort(() => { + // Client disconnected - clear event consumer + const shouldCleanup = session.clearEventConsumer(); + stream.close(); + + // If transport is dead and no client connected, cleanup session + if (shouldCleanup || session.isTransportDead()) { + sessions.delete(sessionId); + } + }); + + // Keep the stream open until the client disconnects. Hono's streamSSE + // closes the stream when this callback returns, so we must not return + // until the connection is aborted. + await new Promise((resolve) => { + stream.onAbort(() => { + // Cleanup happens in onAbort handler above + resolve(); + }); + }); + }); + }); + + app.post("/api/mcp/disconnect", async (c) => { + let body: { sessionId?: string }; + try { + body = (await c.req.json()) as { sessionId?: string }; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const sessionId = body.sessionId; + if (!sessionId) { + return c.json({ error: "Missing sessionId" }, 400); + } + + const session = sessions.get(sessionId); + if (session) { + session.clearEventConsumer(); + await session.transport.close(); + sessions.delete(sessionId); + } + + return c.json({ ok: true }); + }); + + app.post("/api/fetch", async (c) => { + let body: { + url: string; + method?: string; + headers?: Record; + body?: string; + }; + try { + body = (await c.req.json()) as typeof body; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const { url, method = "GET", headers = {}, body: reqBody } = body; + if (!url) { + return c.json({ error: "Missing url" }, 400); + } + + try { + const res = await fetch(url, { + method, + headers: new Headers(headers), + body: reqBody, + }); + + const resHeaders: Record = {}; + res.headers.forEach((v, k) => { + resHeaders[k] = v; + }); + + const contentType = res.headers.get("content-type"); + const isStream = + contentType?.includes("text/event-stream") || + contentType?.includes("application/x-ndjson"); + let resBody: string | undefined; + if (!isStream && res.body) { + resBody = await res.text(); + } + + return c.json({ + ok: res.ok, + status: res.status, + statusText: res.statusText, + headers: resHeaders, + body: resBody, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: msg }, 500); + } + }); + + app.post("/api/log", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Partial; + if (fileLogger) { + forwardLogEvent(fileLogger, body); + } + return c.json({ ok: true }); + }); + + app.get("/api/storage/:storeId", async (c) => { + const storeId = c.req.param("storeId"); + if (!storeId || !validateStoreId(storeId)) { + return c.json({ error: "Invalid storeId" }, 400); + } + + const filePath = getStoreFilePath(storageDir, storeId); + + try { + const raw = await readStoreFile(filePath); + if (raw === null) { + return c.json({}, 200); + } + const store = parseStore(raw); + return c.json(store); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `Failed to read store: ${msg}` }, 500); + } + }); + + app.post("/api/storage/:storeId", async (c) => { + const storeId = c.req.param("storeId"); + if (!storeId || !validateStoreId(storeId)) { + return c.json({ error: "Invalid storeId" }, 400); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const filePath = getStoreFilePath(storageDir, storeId); + + try { + const jsonData = serializeStore(body); + await writeStoreFile(filePath, jsonData); + return c.json({ ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `Failed to write store: ${msg}` }, 500); + } + }); + + app.delete("/api/storage/:storeId", async (c) => { + const storeId = c.req.param("storeId"); + if (!storeId || !validateStoreId(storeId)) { + return c.json({ error: "Invalid storeId" }, 400); + } + + const filePath = getStoreFilePath(storageDir, storeId); + + try { + await deleteStoreFile(filePath); + return c.json({ ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return c.json({ error: `Failed to delete store: ${msg}` }, 500); + } + }); + + return { app, authToken }; +} diff --git a/core/mcp/remote/pino-browser.d.ts b/core/mcp/remote/pino-browser.d.ts new file mode 100644 index 000000000..62d42a807 --- /dev/null +++ b/core/mcp/remote/pino-browser.d.ts @@ -0,0 +1,9 @@ +/** + * Type declaration for pino/browser (has transmit support). + * The pino package provides a browser build at pino/browser. + */ +declare module "pino/browser" { + import type { Logger, LoggerOptions } from "pino"; + function pino(options?: LoggerOptions): Logger; + export = pino; +} diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts new file mode 100644 index 000000000..96accaf78 --- /dev/null +++ b/core/mcp/remote/remoteClientTransport.ts @@ -0,0 +1,332 @@ +/** + * RemoteClientTransport - Transport that talks to a remote server via HTTP. + * Pure TypeScript; works in browser, Deno, or Node. + */ + +import type { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { + JSONRPCMessage, + MessageExtraInfo, +} from "@modelcontextprotocol/sdk/types.js"; +import type { StderrLogEntry } from "../types.js"; +import type { FetchRequestEntryBase } from "../types.js"; +import type { + RemoteConnectRequest, + RemoteConnectResponse, + RemoteEvent, +} from "./types.js"; + +export interface RemoteTransportOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + + /** Optional fetch implementation (for proxy or testing) */ + fetchFn?: typeof fetch; + + /** Callback for stderr from stdio transports (forwarded via remote) */ + onStderr?: (entry: StderrLogEntry) => void; + + /** Callback for fetch request tracking (forwarded via remote) */ + onFetchRequest?: (entry: FetchRequestEntryBase) => void; + + /** Optional OAuth client provider for Bearer authentication */ + authProvider?: import("@modelcontextprotocol/sdk/client/auth.js").OAuthClientProvider; +} + +/** + * Parse SSE stream from a ReadableStream. + * Yields { event, data } for each SSE message. + */ +async function* parseSSE( + reader: ReadableStreamDefaultReader, +): AsyncGenerator<{ event: string; data: string }> { + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + let currentEvent = "message"; + let currentData: string[] = []; + + for (const line of lines) { + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + currentData.push(line.slice(5).trimStart()); + } else if (line === "") { + if (currentData.length > 0) { + yield { event: currentEvent, data: currentData.join("\n") }; + } + currentEvent = "message"; + currentData = []; + } + } + } + + if (buffer.trim()) { + const lines = buffer.split("\n"); + let currentEvent = "message"; + const currentData: string[] = []; + for (const line of lines) { + if (line.startsWith("event:")) currentEvent = line.slice(6).trim(); + else if (line.startsWith("data:")) + currentData.push(line.slice(5).trimStart()); + } + if (currentData.length > 0) { + yield { event: currentEvent, data: currentData.join("\n") }; + } + } +} + +/** + * Transport that forwards JSON-RPC to a remote server and receives responses via SSE. + */ +export class RemoteClientTransport implements Transport { + private _sessionId: string | undefined = undefined; + private eventStreamReader: ReadableStreamDefaultReader | null = + null; + private eventStreamAbort: AbortController | null = null; + private closed = false; + + /** + * Intentionally returns undefined. The MCP Client checks transport.sessionId to detect + * reconnects and skip initialize. Our _sessionId is the remote server's session ID, not + * the MCP protocol's initialization state. Exposing it would cause the MCP Client to + * skip initialize and send tools/list first, which fails on streamable-http (and any + * transport requiring initialize before other requests). + */ + get sessionId(): string | undefined { + return undefined; + } + + constructor( + private readonly options: RemoteTransportOptions, + private readonly config: import("../types.js").MCPServerConfig, + ) {} + + private get fetchFn(): typeof fetch { + return this.options.fetchFn ?? globalThis.fetch; + } + + private get baseUrl(): string { + return this.options.baseUrl.replace(/\/$/, ""); + } + + private get headers(): Record { + const h: Record = { + "Content-Type": "application/json", + }; + if (this.options.authToken) { + h["x-mcp-remote-auth"] = `Bearer ${this.options.authToken}`; + } + return h; + } + + async start(): Promise { + if (this.sessionId) return; + if (this.closed) throw new Error("Transport is closed"); + + // Extract OAuth tokens from authProvider if available + let oauthTokens: RemoteConnectRequest["oauthTokens"] | undefined; + if (this.options.authProvider) { + const tokens = await this.options.authProvider.tokens(); + if (tokens) { + oauthTokens = { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + }; + } + } + + const body: RemoteConnectRequest = { + config: this.config, + oauthTokens, + }; + + const res = await this.fetchFn(`${this.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + // Preserve the status code in the error so callers can detect 401 + const error = new Error(`Remote connect failed (${res.status}): ${text}`); + (error as { status?: number }).status = res.status; + throw error; + } + + const json = (await res.json()) as RemoteConnectResponse; + this._sessionId = json.sessionId; + + if (!this._sessionId) { + throw new Error("Remote did not return sessionId"); + } + + // Open SSE event stream + this.eventStreamAbort = new AbortController(); + const eventRes = await this.fetchFn( + `${this.baseUrl}/api/mcp/events?sessionId=${encodeURIComponent(this._sessionId!)}`, + { + headers: this.options.authToken + ? { "x-mcp-remote-auth": `Bearer ${this.options.authToken}` } + : {}, + signal: this.eventStreamAbort.signal, + }, + ); + + if (!eventRes.ok) { + this._sessionId = undefined; + throw new Error( + `Remote events stream failed (${eventRes.status}): ${await eventRes.text()}`, + ); + } + + const bodyStream = eventRes.body; + if (!bodyStream) { + throw new Error("Remote events stream has no body"); + } + + this.eventStreamReader = bodyStream.getReader(); + this.consumeEventStream(); + } + + private async consumeEventStream(): Promise { + if (!this.eventStreamReader) return; + + try { + for await (const { data } of parseSSE(this.eventStreamReader)) { + if (this.closed) break; + + try { + const parsed = JSON.parse(data) as RemoteEvent; + + if (parsed.type === "message") { + this.onmessage?.(parsed.data as JSONRPCMessage, undefined); + } else if ( + parsed.type === "fetch_request" && + this.options.onFetchRequest + ) { + const entry = parsed.data; + this.options.onFetchRequest({ + ...entry, + timestamp: + typeof entry.timestamp === "string" + ? new Date(entry.timestamp) + : entry.timestamp, + }); + } else if (parsed.type === "stdio_log" && this.options.onStderr) { + this.options.onStderr({ + timestamp: new Date(parsed.data.timestamp), + message: parsed.data.message, + }); + } else if (parsed.type === "transport_error") { + // Transport died - notify client and close (matches local behavior) + const error = new Error(parsed.data.error); + if (parsed.data.code !== undefined) { + (error as { code?: number | string }).code = parsed.data.code; + } + this.onerror?.(error); + // Also trigger onclose to match local transport behavior + if (!this.closed) { + this.closed = true; + this.onclose?.(); + } + } + } catch (err) { + // JSON parse error or other processing error - report but continue + this.onerror?.(err instanceof Error ? err : new Error(String(err))); + } + } + } catch (err) { + // Stream reading error (network issue, abort, etc.) + if (!this.closed && err instanceof Error && err.name !== "AbortError") { + this.onerror?.(err); + } + } finally { + this.eventStreamReader = null; + if (!this.closed) { + this.closed = true; + this.onclose?.(); + } + } + } + + async send( + message: JSONRPCMessage, + options?: TransportSendOptions, + ): Promise { + if (!this._sessionId) { + throw new Error("Transport not started"); + } + if (this.closed) { + throw new Error("Transport is closed"); + } + + const body = { + sessionId: this._sessionId, + message, + ...(options?.relatedRequestId != null && { + relatedRequestId: options.relatedRequestId, + }), + }; + + const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + const error = new Error(`Remote send failed (${res.status}): ${text}`); + (error as { status?: number }).status = res.status; + throw error; + } + } + + async close(): Promise { + if (this.closed) return; + + this.closed = true; + this.eventStreamAbort?.abort(); + this.eventStreamReader = null; + + if (this._sessionId) { + try { + await this.fetchFn(`${this.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: this.headers, + body: JSON.stringify({ sessionId: this._sessionId }), + }); + } catch { + // Ignore disconnect errors + } + this._sessionId = undefined; + } + + this.onclose?.(); + } + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: ( + message: T, + extra?: MessageExtraInfo, + ) => void; +} diff --git a/core/mcp/remote/sessionStorage.ts b/core/mcp/remote/sessionStorage.ts new file mode 100644 index 000000000..593baf417 --- /dev/null +++ b/core/mcp/remote/sessionStorage.ts @@ -0,0 +1,140 @@ +/** + * Remote HTTP storage implementation for InspectorClient session state. + * Uses the remote /api/storage/:storeId endpoint to persist session data + * across page navigations during OAuth flows. + */ + +import type { + InspectorClientStorage, + InspectorClientSessionState, +} from "../sessionStorage.js"; + +export interface RemoteInspectorClientStorageOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + /** Fetch function to use (default: globalThis.fetch) */ + fetchFn?: typeof fetch; +} + +/** + * Remote HTTP storage implementation for InspectorClient session state. + * Stores session data via HTTP API (GET/POST/DELETE /api/storage/:storeId). + * For web clients that need to persist session state across OAuth redirects. + */ +export class RemoteInspectorClientStorage implements InspectorClientStorage { + private baseUrl: string; + private authToken?: string; + private fetchFn: typeof fetch; + + constructor(options: RemoteInspectorClientStorageOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.authToken = options.authToken; + this.fetchFn = options.fetchFn ?? globalThis.fetch; + } + + private getStoreId(sessionId: string): string { + // Use a prefix to distinguish from OAuth storage + return `inspector-session-${sessionId}`; + } + + async saveSession( + sessionId: string, + state: InspectorClientSessionState, + ): Promise { + const storeId = this.getStoreId(sessionId); + const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`; + } + + // Serialize state (convert Date objects to ISO strings for JSON) + const serializedState = { + ...state, + fetchRequests: state.fetchRequests.map((req) => ({ + ...req, + timestamp: + req.timestamp instanceof Date + ? req.timestamp.toISOString() + : req.timestamp, + })), + }; + + const res = await this.fetchFn(url, { + method: "POST", + headers, + body: JSON.stringify(serializedState), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to save session: ${res.status} ${text}`); + } + } + + async loadSession( + sessionId: string, + ): Promise { + const storeId = this.getStoreId(sessionId); + const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`; + + const headers: Record = {}; + if (this.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`; + } + + const res = await this.fetchFn(url, { + method: "GET", + headers, + }); + + if (!res.ok) { + if (res.status === 404) { + return undefined; + } + const text = await res.text(); + throw new Error(`Failed to load session: ${res.status} ${text}`); + } + + const data = (await res.json()) as InspectorClientSessionState; + + // Deserialize state (convert ISO strings back to Date objects) + return { + ...data, + fetchRequests: data.fetchRequests.map((req) => ({ + ...req, + timestamp: + typeof req.timestamp === "string" + ? new Date(req.timestamp) + : req.timestamp instanceof Date + ? req.timestamp + : new Date(req.timestamp), + })), + }; + } + + async deleteSession(sessionId: string): Promise { + const storeId = this.getStoreId(sessionId); + const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`; + + const headers: Record = {}; + if (this.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`; + } + + const res = await this.fetchFn(url, { + method: "DELETE", + headers, + }); + + if (!res.ok && res.status !== 404) { + const text = await res.text(); + throw new Error(`Failed to delete session: ${res.status} ${text}`); + } + } +} diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts new file mode 100644 index 000000000..34331e232 --- /dev/null +++ b/core/mcp/remote/types.ts @@ -0,0 +1,63 @@ +/** + * Types for the remote transport protocol. + */ + +import type { MCPServerConfig, FetchRequestEntryBase } from "../types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +export interface RemoteConnectRequest { + /** MCP server config (stdio, sse, or streamable-http) */ + config: MCPServerConfig; + /** Optional OAuth tokens for Bearer authentication (for HTTP transports) */ + oauthTokens?: { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + }; +} + +export interface RemoteConnectResponse { + sessionId: string; +} + +export interface RemoteSendRequest { + message: JSONRPCMessage; + /** Optional, for associating response with request (e.g. streamable-http) */ + relatedRequestId?: string | number; +} + +export type RemoteEventType = + | "message" + | "fetch_request" + | "stdio_log" + | "transport_error"; + +export interface RemoteEventMessage { + type: "message"; + data: unknown; +} + +export interface RemoteEventFetchRequest { + type: "fetch_request"; + data: FetchRequestEntryBase; +} + +export interface RemoteEventStdioLog { + type: "stdio_log"; + data: { timestamp: string; message: string }; +} + +export interface RemoteEventTransportError { + type: "transport_error"; + data: { + error: string; + code?: string | number; + }; +} + +export type RemoteEvent = + | RemoteEventMessage + | RemoteEventFetchRequest + | RemoteEventStdioLog + | RemoteEventTransportError; diff --git a/core/mcp/samplingCreateMessage.ts b/core/mcp/samplingCreateMessage.ts new file mode 100644 index 000000000..c386cce1c --- /dev/null +++ b/core/mcp/samplingCreateMessage.ts @@ -0,0 +1,68 @@ +import type { + CreateMessageRequest, + CreateMessageResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Represents a pending sampling request from the server + */ +export class SamplingCreateMessage { + public readonly id: string; + public readonly timestamp: Date; + public readonly request: CreateMessageRequest; + public readonly taskId?: string; + private resolvePromise?: (result: CreateMessageResult) => void; + private rejectPromise?: (error: Error) => void; + + constructor( + request: CreateMessageRequest, + resolve: (result: CreateMessageResult) => void, + reject: (error: Error) => void, + private onRemove: (id: string) => void, + ) { + this.id = `sampling-${Date.now()}-${Math.random()}`; + this.timestamp = new Date(); + this.request = request; + // Extract taskId from request params metadata if present + const relatedTask = request.params?._meta?.[RELATED_TASK_META_KEY]; + this.taskId = relatedTask?.taskId; + this.resolvePromise = resolve; + this.rejectPromise = reject; + } + + /** + * Respond to the sampling request with a result + */ + async respond(result: CreateMessageResult): Promise { + if (!this.resolvePromise) { + throw new Error("Request already resolved or rejected"); + } + this.resolvePromise(result); + this.resolvePromise = undefined; + this.rejectPromise = undefined; + // Remove from pending list after responding + this.remove(); + } + + /** + * Reject the sampling request with an error + */ + async reject(error: Error): Promise { + if (!this.rejectPromise) { + throw new Error("Request already resolved or rejected"); + } + this.rejectPromise(error); + this.resolvePromise = undefined; + this.rejectPromise = undefined; + // Remove from pending list after rejecting + this.remove(); + } + + /** + * Remove this pending sample from the list + */ + remove(): void { + this.onRemove(this.id); + } +} diff --git a/core/mcp/sessionStorage.ts b/core/mcp/sessionStorage.ts new file mode 100644 index 000000000..ddd582c7a --- /dev/null +++ b/core/mcp/sessionStorage.ts @@ -0,0 +1,46 @@ +import type { FetchRequestEntry } from "./types.js"; + +/** + * Serialized session state for InspectorClient. + * Contains data that should persist across page navigations (e.g., OAuth redirects). + */ +export interface InspectorClientSessionState { + /** Fetch requests tracked during the session */ + fetchRequests: FetchRequestEntry[]; + /** Timestamp when session was created */ + createdAt: number; + /** Timestamp when session was last updated */ + updatedAt: number; +} + +/** + * Storage interface for persisting InspectorClient session state. + * Used to maintain session data (e.g., fetch requests) across page navigations + * during OAuth flows. + */ +export interface InspectorClientStorage { + /** + * Save InspectorClient session state. + * @param sessionId - Unique session identifier (typically from OAuth state authId) + * @param state - Serialized InspectorClient state + */ + saveSession( + sessionId: string, + state: InspectorClientSessionState, + ): Promise; + + /** + * Load InspectorClient session state. + * @param sessionId - Unique session identifier + * @returns Session state or undefined if not found + */ + loadSession( + sessionId: string, + ): Promise; + + /** + * Delete session state (cleanup). + * @param sessionId - Unique session identifier + */ + deleteSession(sessionId: string): Promise; +} diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts new file mode 100644 index 000000000..da975c74d --- /dev/null +++ b/core/mcp/state/fetchRequestLogState.ts @@ -0,0 +1,153 @@ +/** + * FetchRequestLogState: holds fetch request log, subscribes to protocol "fetchRequest" events. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + * Protocol emits only per-entry "fetchRequest" (with payload); this manager owns the list + * and emits fetchRequest + fetchRequestsChange on append, fetchRequestsChange on clear. + * Mirrors InspectorClient: maxFetchRequests trim, getFetchRequests(), clearFetchRequests(). + * Does not clear on connect/disconnect (client does not clear fetch log). + * + * When sessionStorage and sessionId are provided, restores fetch requests from storage on + * creation and listens for the client's "saveSession" event to persist before OAuth redirect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { FetchRequestEntry } from "../types.js"; +import type { + InspectorClientStorage, + InspectorClientSessionState, +} from "../sessionStorage.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface FetchRequestLogStateEventMap { + fetchRequest: FetchRequestEntry; + fetchRequestsChange: FetchRequestEntry[]; +} + +export interface FetchRequestLogStateOptions { + /** + * Maximum number of fetch requests to store (0 = unlimited, not recommended). + * When exceeded, oldest entries are dropped. Default 1000, matching InspectorClient. + */ + maxFetchRequests?: number; + /** + * When provided with sessionId, fetch requests are restored from storage on creation + * and saved when the client dispatches "saveSession" (e.g. before OAuth redirect). + */ + sessionStorage?: InspectorClientStorage; + /** Session ID for load/save; required for sessionStorage to have effect. */ + sessionId?: string; +} + +/** + * State manager that holds the fetch request log. Subscribes to the protocol's "fetchRequest" + * event (per-entry with payload); appends to its list (trimming to maxFetchRequests when set), + * then dispatches "fetchRequest" (payload) and "fetchRequestsChange" (full list). + * getFetchRequests() and clearFetchRequests() match InspectorClient API. + * Does not clear on connect or disconnect: pre-connect and post-connect entries both remain. + * With sessionStorage + sessionId, restores on creation and saves on client "saveSession" event. + */ +export class FetchRequestLogState extends TypedEventTarget { + private fetchRequests: FetchRequestEntry[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private readonly maxFetchRequests: number; + + constructor( + client: InspectorClient, + options: FetchRequestLogStateOptions = {}, + ) { + super(); + this.maxFetchRequests = options.maxFetchRequests ?? 1000; + this.client = client; + + const onFetchRequest = (event: Event): void => { + const entry = (event as CustomEvent).detail; + if ( + this.maxFetchRequests > 0 && + this.fetchRequests.length >= this.maxFetchRequests + ) { + this.fetchRequests.shift(); + } + this.fetchRequests.push(entry); + this.dispatchTypedEvent("fetchRequest", entry); + this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); + }; + this.client.addEventListener("fetchRequest", onFetchRequest); + + const sessionStorage = options.sessionStorage; + const sessionId = options.sessionId; + + // Listen for saveSession whenever we have sessionStorage (sessionId is in the event before redirect) + if (sessionStorage) { + const onSaveSession = (event: Event): void => { + const { sessionId: id } = (event as CustomEvent<{ sessionId: string }>) + .detail; + const state: InspectorClientSessionState = { + fetchRequests: this.getFetchRequests(), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + sessionStorage.saveSession(id, state).catch(() => { + // Fire-and-forget; storage may log internally + }); + }; + this.client.addEventListener("saveSession", onSaveSession); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("fetchRequest", onFetchRequest); + this.client.removeEventListener("saveSession", onSaveSession); + } + this.client = null; + }; + // Restore when we have sessionId (e.g. after OAuth callback) + if (sessionId) { + sessionStorage.loadSession(sessionId).then((state) => { + if (this.client && state?.fetchRequests?.length) { + this.hydrateFetchRequests(state.fetchRequests); + } + }); + } + } else { + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("fetchRequest", onFetchRequest); + } + this.client = null; + }; + } + } + + /** + * Replace current list with restored entries (e.g. from session load). Dispatches fetchRequestsChange. + */ + private hydrateFetchRequests(entries: FetchRequestEntry[]): void { + const trimmed = + this.maxFetchRequests > 0 + ? entries.slice(-this.maxFetchRequests) + : entries; + this.fetchRequests = trimmed; + this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); + } + + getFetchRequests(): FetchRequestEntry[] { + return [...this.fetchRequests]; + } + + /** + * Clear all fetch requests. Dispatches fetchRequestsChange only if the list was non-empty. + */ + clearFetchRequests(): void { + if (this.fetchRequests.length === 0) return; + this.fetchRequests = []; + this.dispatchTypedEvent("fetchRequestsChange", []); + } + + /** + * Stop listening to the client and clear state. Call when switching clients. + */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.fetchRequests = []; + } +} diff --git a/core/mcp/state/index.ts b/core/mcp/state/index.ts new file mode 100644 index 000000000..7f1cb2a37 --- /dev/null +++ b/core/mcp/state/index.ts @@ -0,0 +1,50 @@ +export { ManagedToolsState } from "./managedToolsState.js"; +export type { ManagedToolsStateEventMap } from "./managedToolsState.js"; +export { MessageLogState } from "./messageLogState.js"; +export type { + MessageLogStateEventMap, + MessageLogStateOptions, +} from "./messageLogState.js"; +export { FetchRequestLogState } from "./fetchRequestLogState.js"; +export type { + FetchRequestLogStateEventMap, + FetchRequestLogStateOptions, +} from "./fetchRequestLogState.js"; +export { PagedToolsState } from "./pagedToolsState.js"; +export type { + PagedToolsStateEventMap, + LoadPageResult, +} from "./pagedToolsState.js"; +export { StderrLogState } from "./stderrLogState.js"; +export type { + StderrLogStateEventMap, + StderrLogStateOptions, +} from "./stderrLogState.js"; +export { ManagedResourcesState } from "./managedResourcesState.js"; +export type { ManagedResourcesStateEventMap } from "./managedResourcesState.js"; +export { PagedResourcesState } from "./pagedResourcesState.js"; +export type { + PagedResourcesStateEventMap, + LoadPageResult as PagedResourcesLoadPageResult, +} from "./pagedResourcesState.js"; +export { ManagedResourceTemplatesState } from "./managedResourceTemplatesState.js"; +export type { ManagedResourceTemplatesStateEventMap } from "./managedResourceTemplatesState.js"; +export { PagedResourceTemplatesState } from "./pagedResourceTemplatesState.js"; +export type { + PagedResourceTemplatesStateEventMap, + LoadPageResult as PagedResourceTemplatesLoadPageResult, +} from "./pagedResourceTemplatesState.js"; +export { ManagedPromptsState } from "./managedPromptsState.js"; +export type { ManagedPromptsStateEventMap } from "./managedPromptsState.js"; +export { PagedPromptsState } from "./pagedPromptsState.js"; +export type { + PagedPromptsStateEventMap, + LoadPageResult as PagedPromptsLoadPageResult, +} from "./pagedPromptsState.js"; +export { ManagedRequestorTasksState } from "./managedRequestorTasksState.js"; +export type { ManagedRequestorTasksStateEventMap } from "./managedRequestorTasksState.js"; +export { PagedRequestorTasksState } from "./pagedRequestorTasksState.js"; +export type { + PagedRequestorTasksStateEventMap, + LoadPageResult as PagedRequestorTasksLoadPageResult, +} from "./pagedRequestorTasksState.js"; diff --git a/core/mcp/state/managedPromptsState.ts b/core/mcp/state/managedPromptsState.ts new file mode 100644 index 000000000..f3758cab3 --- /dev/null +++ b/core/mcp/state/managedPromptsState.ts @@ -0,0 +1,101 @@ +/** + * ManagedPromptsState: holds full prompt list, syncs on promptsListChanged. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +const MAX_PAGES = 100; + +export interface ManagedPromptsStateEventMap { + promptsChange: Prompt[]; +} + +/** + * State manager that keeps a full prompt list in sync with the server. + * Subscribes to connect, promptsListChanged, and statusChange; fetches all pages on refresh. + * Clears content cache for removed prompt names when the list shrinks after refresh. + */ +export class ManagedPromptsState extends TypedEventTarget { + private prompts: Prompt[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private _metadata: Record | undefined = undefined; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onConnect = (): void => { + void this.refresh(); + }; + const onPromptsListChanged = (): void => { + void this.refresh(); + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.prompts = []; + this.dispatchTypedEvent("promptsChange", []); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener("promptsListChanged", onPromptsListChanged); + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener( + "promptsListChanged", + onPromptsListChanged, + ); + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getPrompts(): Prompt[] { + return [...this.prompts]; + } + + setMetadata(metadata?: Record): void { + this._metadata = metadata; + } + + /** + * Fetch all pages of prompts and update state; dispatches promptsChange when done. + */ + async refresh(metadata?: Record): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") { + return this.getPrompts(); + } + const effectiveMetadata = metadata ?? this._metadata; + this.prompts = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const result = await client.listPrompts(cursor, effectiveMetadata); + this.prompts = + cursor === undefined + ? result.prompts + : [...this.prompts, ...result.prompts]; + cursor = result.nextCursor; + pageCount++; + if (pageCount >= MAX_PAGES) { + throw new Error( + `Maximum pagination limit (${MAX_PAGES} pages) reached while listing prompts`, + ); + } + } while (cursor); + this.dispatchTypedEvent("promptsChange", this.prompts); + return this.getPrompts(); + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.prompts = []; + } +} diff --git a/core/mcp/state/managedRequestorTasksState.ts b/core/mcp/state/managedRequestorTasksState.ts new file mode 100644 index 000000000..27fe42ac5 --- /dev/null +++ b/core/mcp/state/managedRequestorTasksState.ts @@ -0,0 +1,152 @@ +/** + * ManagedRequestorTasksState: holds full requestor task list, syncs on tasksListChanged. + * Subscribes to taskStatusChange (server) and requestorTaskUpdated (client) to merge updates. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { TaskWithOptionalCreatedAt } from "../inspectorClientEventTarget.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +const MAX_PAGES = 100; + +export interface ManagedRequestorTasksStateEventMap { + tasksChange: Task[]; +} + +function mergeTaskIntoList( + tasks: Task[], + taskId: string, + task: Task | TaskWithOptionalCreatedAt, +): Task[] { + const normalized: Task = { + ...task, + taskId, + createdAt: + (task as Task).createdAt ?? + (task as TaskWithOptionalCreatedAt).lastUpdatedAt ?? + "", + }; + const idx = tasks.findIndex((t) => t.taskId === taskId); + if (idx < 0) { + return [normalized, ...tasks]; + } + const next = [...tasks]; + next[idx] = normalized; + return next; +} + +/** + * State manager that keeps the full requestor task list in sync with the server. + * Subscribes to connect, tasksListChanged, statusChange, taskStatusChange (server), and requestorTaskUpdated (client). + */ +export class ManagedRequestorTasksState extends TypedEventTarget { + private tasks: Task[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onConnect = (): void => { + void this.refresh(); + }; + const onTasksListChanged = (): void => { + void this.refresh(); + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.tasks = []; + this.dispatchTypedEvent("tasksChange", []); + } + }; + const onTaskStatusChange = ( + e: CustomEvent<{ taskId: string; task: Task }>, + ): void => { + const { taskId, task } = e.detail; + this.tasks = mergeTaskIntoList(this.tasks, taskId, task); + this.dispatchTypedEvent("tasksChange", this.tasks); + }; + const onRequestorTaskUpdated = ( + e: CustomEvent<{ + taskId: string; + task: TaskWithOptionalCreatedAt; + }>, + ): void => { + const { taskId, task } = e.detail; + this.tasks = mergeTaskIntoList(this.tasks, taskId, task); + this.dispatchTypedEvent("tasksChange", this.tasks); + }; + const onTaskCancelled = (e: CustomEvent<{ taskId: string }>): void => { + const { taskId } = e.detail; + const idx = this.tasks.findIndex((t) => t.taskId === taskId); + if (idx >= 0) { + const next = [...this.tasks]; + const prev = next[idx]!; + next[idx] = { ...prev, status: "cancelled" as const }; + this.tasks = next; + this.dispatchTypedEvent("tasksChange", this.tasks); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener("tasksListChanged", onTasksListChanged); + this.client.addEventListener("statusChange", onStatusChange); + this.client.addEventListener("taskStatusChange", onTaskStatusChange); + this.client.addEventListener( + "requestorTaskUpdated", + onRequestorTaskUpdated, + ); + this.client.addEventListener("taskCancelled", onTaskCancelled); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener("tasksListChanged", onTasksListChanged); + this.client.removeEventListener("statusChange", onStatusChange); + this.client.removeEventListener("taskStatusChange", onTaskStatusChange); + this.client.removeEventListener( + "requestorTaskUpdated", + onRequestorTaskUpdated, + ); + this.client.removeEventListener("taskCancelled", onTaskCancelled); + } + this.client = null; + }; + } + + getTasks(): Task[] { + return [...this.tasks]; + } + + /** + * Fetch all pages of requestor tasks and update state; dispatches tasksChange when done. + */ + async refresh(): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") { + return this.getTasks(); + } + this.tasks = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const result = await client.listRequestorTasks(cursor); + this.tasks = + cursor === undefined ? result.tasks : [...this.tasks, ...result.tasks]; + cursor = result.nextCursor; + pageCount++; + if (pageCount >= MAX_PAGES) { + throw new Error( + `Maximum pagination limit (${MAX_PAGES} pages) reached while listing requestor tasks`, + ); + } + } while (cursor); + this.dispatchTypedEvent("tasksChange", this.tasks); + return this.getTasks(); + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.tasks = []; + } +} diff --git a/core/mcp/state/managedResourceTemplatesState.ts b/core/mcp/state/managedResourceTemplatesState.ts new file mode 100644 index 000000000..d7662e79e --- /dev/null +++ b/core/mcp/state/managedResourceTemplatesState.ts @@ -0,0 +1,109 @@ +/** + * ManagedResourceTemplatesState: holds full resource template list, syncs on resourceTemplatesListChanged. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +const MAX_PAGES = 100; + +export interface ManagedResourceTemplatesStateEventMap { + resourceTemplatesChange: ResourceTemplate[]; +} + +/** + * State manager that keeps a full resource template list in sync with the server. + * Subscribes to connect, resourceTemplatesListChanged, and statusChange; fetches all pages on refresh. + * Clears content cache for removed URI templates when the list shrinks after refresh. + */ +export class ManagedResourceTemplatesState extends TypedEventTarget { + private resourceTemplates: ResourceTemplate[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private _metadata: Record | undefined = undefined; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onConnect = (): void => { + void this.refresh(); + }; + const onResourceTemplatesListChanged = (): void => { + void this.refresh(); + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.resourceTemplates = []; + this.dispatchTypedEvent("resourceTemplatesChange", []); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener( + "resourceTemplatesListChanged", + onResourceTemplatesListChanged, + ); + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener( + "resourceTemplatesListChanged", + onResourceTemplatesListChanged, + ); + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getResourceTemplates(): ResourceTemplate[] { + return [...this.resourceTemplates]; + } + + setMetadata(metadata?: Record): void { + this._metadata = metadata; + } + + /** + * Fetch all pages of resource templates and update state; dispatches resourceTemplatesChange when done. + */ + async refresh( + metadata?: Record, + ): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") { + return this.getResourceTemplates(); + } + const effectiveMetadata = metadata ?? this._metadata; + this.resourceTemplates = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const result = await client.listResourceTemplates( + cursor, + effectiveMetadata, + ); + this.resourceTemplates = + cursor === undefined + ? result.resourceTemplates + : [...this.resourceTemplates, ...result.resourceTemplates]; + cursor = result.nextCursor; + pageCount++; + if (pageCount >= MAX_PAGES) { + throw new Error( + `Maximum pagination limit (${MAX_PAGES} pages) reached while listing resource templates`, + ); + } + } while (cursor); + this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates); + return this.getResourceTemplates(); + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.resourceTemplates = []; + } +} diff --git a/core/mcp/state/managedResourcesState.ts b/core/mcp/state/managedResourcesState.ts new file mode 100644 index 000000000..f0ebf3c92 --- /dev/null +++ b/core/mcp/state/managedResourcesState.ts @@ -0,0 +1,104 @@ +/** + * ManagedResourcesState: holds full resource list, syncs on resourcesListChanged. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +const MAX_PAGES = 100; + +export interface ManagedResourcesStateEventMap { + resourcesChange: Resource[]; +} + +/** + * State manager that keeps a full resource list in sync with the server. + * Subscribes to connect, resourcesListChanged, and statusChange; fetches all pages on refresh. + * Clears content cache for removed resource URIs when the list shrinks after refresh. + */ +export class ManagedResourcesState extends TypedEventTarget { + private resources: Resource[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private _metadata: Record | undefined = undefined; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onConnect = (): void => { + void this.refresh(); + }; + const onResourcesListChanged = (): void => { + void this.refresh(); + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.resources = []; + this.dispatchTypedEvent("resourcesChange", []); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener( + "resourcesListChanged", + onResourcesListChanged, + ); + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener( + "resourcesListChanged", + onResourcesListChanged, + ); + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getResources(): Resource[] { + return [...this.resources]; + } + + setMetadata(metadata?: Record): void { + this._metadata = metadata; + } + + /** + * Fetch all pages of resources and update state; dispatches resourcesChange when done. + */ + async refresh(metadata?: Record): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") { + return this.getResources(); + } + const effectiveMetadata = metadata ?? this._metadata; + this.resources = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const result = await client.listResources(cursor, effectiveMetadata); + this.resources = + cursor === undefined + ? result.resources + : [...this.resources, ...result.resources]; + cursor = result.nextCursor; + pageCount++; + if (pageCount >= MAX_PAGES) { + throw new Error( + `Maximum pagination limit (${MAX_PAGES} pages) reached while listing resources`, + ); + } + } while (cursor); + this.dispatchTypedEvent("resourcesChange", this.resources); + return this.getResources(); + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.resources = []; + } +} diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts new file mode 100644 index 000000000..7fa563458 --- /dev/null +++ b/core/mcp/state/managedToolsState.ts @@ -0,0 +1,104 @@ +/** + * ManagedToolsState: holds full tool list, syncs on toolsListChanged. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +const MAX_PAGES = 100; + +export interface ManagedToolsStateEventMap { + toolsChange: Tool[]; +} + +/** + * State manager that keeps a full tool list in sync with the server. + * Subscribes to client's connect (initial load), toolsListChanged, and statusChange; fetches all pages on refresh. + * If the caller wants metadata on list_tools (e.g. CLI --metadata), set it via setMetadata() so internal refresh() calls use it. + */ +export class ManagedToolsState extends TypedEventTarget { + private tools: Tool[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private _metadata: Record | undefined = undefined; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onConnect = (): void => { + void this.refresh(); + }; + const onToolsListChanged = (): void => { + void this.refresh(); + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.tools = []; + this.dispatchTypedEvent("toolsChange", []); + } + }; + this.client.addEventListener("connect", onConnect); + this.client.addEventListener("toolsListChanged", onToolsListChanged); + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("connect", onConnect); + this.client.removeEventListener("toolsListChanged", onToolsListChanged); + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getTools(): Tool[] { + return [...this.tools]; + } + + /** + * Set metadata to include in list_tools when refresh() is called (including internal calls on connect / toolsListChanged). + * Call this when the caller has metadata (e.g. CLI --metadata) so every refresh uses it. + */ + setMetadata(metadata?: Record): void { + this._metadata = metadata; + } + + /** + * Fetch all pages of tools and update state; dispatches toolsChange when done. + * Uses listTools() so the client's own list state is not modified. + * Uses passed-in metadata for this call, or the metadata set via setMetadata() if none passed. + */ + async refresh(metadata?: Record): Promise { + const client = this.client; + if (!client || client.getStatus() !== "connected") { + return this.getTools(); + } + const effectiveMetadata = metadata ?? this._metadata; + this.tools = []; + let cursor: string | undefined; + let pageCount = 0; + do { + const result = await client.listTools(cursor, effectiveMetadata); + this.tools = cursor ? [...this.tools, ...result.tools] : result.tools; + cursor = result.nextCursor; + pageCount++; + if (pageCount >= MAX_PAGES) { + throw new Error( + `Maximum pagination limit (${MAX_PAGES} pages) reached while listing tools`, + ); + } + } while (cursor); + this.dispatchTypedEvent("toolsChange", this.tools); + return this.getTools(); + } + + /** + * Stop listening to the client and clear state. Call when switching clients. + */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.tools = []; + } +} diff --git a/core/mcp/state/messageLogState.ts b/core/mcp/state/messageLogState.ts new file mode 100644 index 000000000..e9ab8727b --- /dev/null +++ b/core/mcp/state/messageLogState.ts @@ -0,0 +1,157 @@ +/** + * MessageLogState: holds message log, subscribes to protocol "message" events. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + * Protocol emits only per-entry "message" (with payload); this manager owns the list + * and emits message + messagesChange on append/update, messagesChange on clear. + * Mirrors InspectorClient message-list behavior: maxMessages trim, getMessages(predicate?), clearMessages(predicate?), clear on connect/disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { MessageEntry } from "../types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface MessageLogStateEventMap { + message: MessageEntry; + messagesChange: MessageEntry[]; +} + +export interface MessageLogStateOptions { + /** + * Maximum number of messages to store (0 = unlimited, not recommended). + * When exceeded, oldest entries are dropped. Default 1000, matching InspectorClient. + */ + maxMessages?: number; +} + +/** + * State manager that holds the message log. Subscribes to the protocol's "message" + * event (per-entry with payload); appends or updates the entry in its list (trimming + * to maxMessages when set), then dispatches "message" (payload) and "messagesChange" (full list). + * On connect, clears list (fresh session). On disconnect, clears and dispatches messagesChange. + * getMessages(predicate?) and clearMessages(predicate?) match InspectorClient API. + */ +export class MessageLogState extends TypedEventTarget { + private messages: MessageEntry[] = []; + /** Pending request entries by JSON-RPC message id for matching responses. */ + private pendingRequestEntries = new Map(); + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private readonly maxMessages: number; + + constructor(client: InspectorClient, options: MessageLogStateOptions = {}) { + super(); + this.maxMessages = options.maxMessages ?? 1000; + this.client = client; + const onMessage = (event: Event): void => { + const entry = (event as CustomEvent).detail; + if (entry.direction === "request") { + if ( + "id" in entry.message && + (entry.message as { id?: string | number }).id !== undefined + ) { + this.pendingRequestEntries.set( + (entry.message as { id: string | number }).id, + entry, + ); + } + if (this.maxMessages > 0 && this.messages.length >= this.maxMessages) { + this.messages.shift(); + } + this.messages.push(entry); + this.dispatchTypedEvent("message", entry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + } else if (entry.direction === "response") { + const messageId = + "id" in entry.message + ? (entry.message as { id?: string | number }).id + : undefined; + const requestEntry = + messageId !== undefined + ? this.pendingRequestEntries.get(messageId) + : undefined; + if (requestEntry) { + this.pendingRequestEntries.delete(messageId!); + requestEntry.response = entry.message as MessageEntry["response"]; + requestEntry.duration = + entry.timestamp.getTime() - requestEntry.timestamp.getTime(); + this.dispatchTypedEvent("message", requestEntry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + } else { + if ( + this.maxMessages > 0 && + this.messages.length >= this.maxMessages + ) { + this.messages.shift(); + } + this.messages.push(entry); + this.dispatchTypedEvent("message", entry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + } + } else { + if (this.maxMessages > 0 && this.messages.length >= this.maxMessages) { + this.messages.shift(); + } + this.messages.push(entry); + this.dispatchTypedEvent("message", entry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + } + }; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.messages = []; + this.pendingRequestEntries.clear(); + this.dispatchTypedEvent("messagesChange", []); + } + }; + const onConnect = (): void => { + this.messages = []; + this.pendingRequestEntries.clear(); + this.dispatchTypedEvent("messagesChange", []); + }; + this.client.addEventListener("message", onMessage); + this.client.addEventListener("statusChange", onStatusChange); + this.client.addEventListener("connect", onConnect); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("message", onMessage); + this.client.removeEventListener("statusChange", onStatusChange); + this.client.removeEventListener("connect", onConnect); + } + this.client = null; + }; + } + + /** + * Get messages. When predicate is provided, returns only entries for which + * predicate returns true. When omitted, returns all messages. + */ + getMessages(predicate?: (entry: MessageEntry) => boolean): MessageEntry[] { + if (predicate) { + return this.messages.filter(predicate); + } + return [...this.messages]; + } + + /** + * Remove messages from history. When predicate is provided, removes only entries + * for which predicate returns true. When omitted, clears all messages. + * Dispatches messagesChange only if the list actually changed. + */ + clearMessages(predicate?: (entry: MessageEntry) => boolean): void { + const before = this.messages.length; + this.messages = predicate ? this.messages.filter((m) => !predicate(m)) : []; + if (this.messages.length !== before) { + this.dispatchTypedEvent("messagesChange", this.getMessages()); + } + } + + /** + * Stop listening to the client and clear state. Call when switching clients. + */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.messages = []; + this.pendingRequestEntries.clear(); + } +} diff --git a/core/mcp/state/pagedPromptsState.ts b/core/mcp/state/pagedPromptsState.ts new file mode 100644 index 000000000..a77902b31 --- /dev/null +++ b/core/mcp/state/pagedPromptsState.ts @@ -0,0 +1,81 @@ +/** + * PagedPromptsState: holds an aggregated list of prompts loaded via loadPage(cursor). + * Does not load on connect; caller drives loading. Clears on disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface PagedPromptsStateEventMap { + promptsChange: Prompt[]; +} + +export interface LoadPageResult { + prompts: Prompt[]; + nextCursor?: string; +} + +/** + * State manager that holds the union of prompts loaded via loadPage(). + * Subscribes only to statusChange to clear on disconnect. + */ +export class PagedPromptsState extends TypedEventTarget { + private prompts: Prompt[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.prompts = []; + this.dispatchTypedEvent("promptsChange", []); + } + }; + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getPrompts(): Prompt[] { + return [...this.prompts]; + } + + clear(): void { + this.prompts = []; + this.dispatchTypedEvent("promptsChange", this.prompts); + } + + async loadPage( + cursor?: string, + metadata?: Record, + ): Promise { + const c = this.client; + if (!c || c.getStatus() !== "connected") { + return { prompts: [], nextCursor: undefined }; + } + const result = await c.listPrompts(cursor, metadata); + if (cursor === undefined) { + this.prompts = [...result.prompts]; + } else { + this.prompts = [...this.prompts, ...result.prompts]; + } + this.dispatchTypedEvent("promptsChange", this.prompts); + return { + prompts: result.prompts, + nextCursor: result.nextCursor, + }; + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.prompts = []; + } +} diff --git a/core/mcp/state/pagedRequestorTasksState.ts b/core/mcp/state/pagedRequestorTasksState.ts new file mode 100644 index 000000000..9de89b1a7 --- /dev/null +++ b/core/mcp/state/pagedRequestorTasksState.ts @@ -0,0 +1,156 @@ +/** + * PagedRequestorTasksState: holds an aggregated list of requestor tasks loaded via loadPage(cursor). + * Subscribes to tasksListChanged (refetch first page), taskStatusChange, requestorTaskUpdated, taskCancelled. + * Clears on disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { TaskWithOptionalCreatedAt } from "../inspectorClientEventTarget.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface PagedRequestorTasksStateEventMap { + tasksChange: Task[]; +} + +export interface LoadPageResult { + tasks: Task[]; + nextCursor?: string; +} + +function mergeTaskIntoList( + tasks: Task[], + taskId: string, + task: Task | TaskWithOptionalCreatedAt, +): Task[] { + const normalized: Task = { + ...task, + taskId, + createdAt: + (task as Task).createdAt ?? + (task as TaskWithOptionalCreatedAt).lastUpdatedAt ?? + "", + }; + const idx = tasks.findIndex((t) => t.taskId === taskId); + if (idx < 0) { + return [normalized, ...tasks]; + } + const next = [...tasks]; + next[idx] = normalized; + return next; +} + +/** + * State manager that holds the union of requestor tasks loaded via loadPage(). + * Subscribes to tasksListChanged (refetch first page), taskStatusChange, requestorTaskUpdated, taskCancelled, statusChange. + */ +export class PagedRequestorTasksState extends TypedEventTarget { + private tasks: Task[] = []; + private nextCursor: string | undefined = undefined; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.tasks = []; + this.nextCursor = undefined; + this.dispatchTypedEvent("tasksChange", []); + } + }; + const onTasksListChanged = (): void => { + void this.loadPage(undefined); + }; + const onTaskStatusChange = ( + e: CustomEvent<{ taskId: string; task: Task }>, + ): void => { + const { taskId, task } = e.detail; + this.tasks = mergeTaskIntoList(this.tasks, taskId, task); + this.dispatchTypedEvent("tasksChange", this.tasks); + }; + const onRequestorTaskUpdated = ( + e: CustomEvent<{ + taskId: string; + task: TaskWithOptionalCreatedAt; + }>, + ): void => { + const { taskId, task } = e.detail; + this.tasks = mergeTaskIntoList(this.tasks, taskId, task); + this.dispatchTypedEvent("tasksChange", this.tasks); + }; + const onTaskCancelled = (e: CustomEvent<{ taskId: string }>): void => { + const { taskId } = e.detail; + const idx = this.tasks.findIndex((t) => t.taskId === taskId); + if (idx >= 0) { + const next = [...this.tasks]; + const prev = next[idx]!; + next[idx] = { ...prev, status: "cancelled" as const }; + this.tasks = next; + this.dispatchTypedEvent("tasksChange", this.tasks); + } + }; + this.client.addEventListener("statusChange", onStatusChange); + this.client.addEventListener("tasksListChanged", onTasksListChanged); + this.client.addEventListener("taskStatusChange", onTaskStatusChange); + this.client.addEventListener( + "requestorTaskUpdated", + onRequestorTaskUpdated, + ); + this.client.addEventListener("taskCancelled", onTaskCancelled); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("statusChange", onStatusChange); + this.client.removeEventListener("tasksListChanged", onTasksListChanged); + this.client.removeEventListener("taskStatusChange", onTaskStatusChange); + this.client.removeEventListener( + "requestorTaskUpdated", + onRequestorTaskUpdated, + ); + this.client.removeEventListener("taskCancelled", onTaskCancelled); + } + this.client = null; + }; + } + + getTasks(): Task[] { + return [...this.tasks]; + } + + getNextCursor(): string | undefined { + return this.nextCursor; + } + + clear(): void { + this.tasks = []; + this.nextCursor = undefined; + this.dispatchTypedEvent("tasksChange", this.tasks); + } + + async loadPage(cursor?: string): Promise { + const c = this.client; + if (!c || c.getStatus() !== "connected") { + return { tasks: [], nextCursor: undefined }; + } + const result = await c.listRequestorTasks(cursor); + if (cursor === undefined) { + this.tasks = [...result.tasks]; + } else { + this.tasks = [...this.tasks, ...result.tasks]; + } + this.nextCursor = result.nextCursor; + this.dispatchTypedEvent("tasksChange", this.tasks); + return { + tasks: result.tasks, + nextCursor: result.nextCursor, + }; + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.tasks = []; + this.nextCursor = undefined; + } +} diff --git a/core/mcp/state/pagedResourceTemplatesState.ts b/core/mcp/state/pagedResourceTemplatesState.ts new file mode 100644 index 000000000..24dbf4e2d --- /dev/null +++ b/core/mcp/state/pagedResourceTemplatesState.ts @@ -0,0 +1,84 @@ +/** + * PagedResourceTemplatesState: holds an aggregated list of resource templates loaded via loadPage(cursor). + * Does not load on connect; caller drives loading. Clears on disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface PagedResourceTemplatesStateEventMap { + resourceTemplatesChange: ResourceTemplate[]; +} + +export interface LoadPageResult { + resourceTemplates: ResourceTemplate[]; + nextCursor?: string; +} + +/** + * State manager that holds the union of resource templates loaded via loadPage(). + * Subscribes only to statusChange to clear on disconnect. + */ +export class PagedResourceTemplatesState extends TypedEventTarget { + private resourceTemplates: ResourceTemplate[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.resourceTemplates = []; + this.dispatchTypedEvent("resourceTemplatesChange", []); + } + }; + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getResourceTemplates(): ResourceTemplate[] { + return [...this.resourceTemplates]; + } + + clear(): void { + this.resourceTemplates = []; + this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates); + } + + async loadPage( + cursor?: string, + metadata?: Record, + ): Promise { + const c = this.client; + if (!c || c.getStatus() !== "connected") { + return { resourceTemplates: [], nextCursor: undefined }; + } + const result = await c.listResourceTemplates(cursor, metadata); + if (cursor === undefined) { + this.resourceTemplates = [...result.resourceTemplates]; + } else { + this.resourceTemplates = [ + ...this.resourceTemplates, + ...result.resourceTemplates, + ]; + } + this.dispatchTypedEvent("resourceTemplatesChange", this.resourceTemplates); + return { + resourceTemplates: result.resourceTemplates, + nextCursor: result.nextCursor, + }; + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.resourceTemplates = []; + } +} diff --git a/core/mcp/state/pagedResourcesState.ts b/core/mcp/state/pagedResourcesState.ts new file mode 100644 index 000000000..7d65024d7 --- /dev/null +++ b/core/mcp/state/pagedResourcesState.ts @@ -0,0 +1,81 @@ +/** + * PagedResourcesState: holds an aggregated list of resources loaded via loadPage(cursor). + * Does not load on connect; caller drives loading. Clears on disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface PagedResourcesStateEventMap { + resourcesChange: Resource[]; +} + +export interface LoadPageResult { + resources: Resource[]; + nextCursor?: string; +} + +/** + * State manager that holds the union of resources loaded via loadPage(). + * Subscribes only to statusChange to clear on disconnect. + */ +export class PagedResourcesState extends TypedEventTarget { + private resources: Resource[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.resources = []; + this.dispatchTypedEvent("resourcesChange", []); + } + }; + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getResources(): Resource[] { + return [...this.resources]; + } + + clear(): void { + this.resources = []; + this.dispatchTypedEvent("resourcesChange", this.resources); + } + + async loadPage( + cursor?: string, + metadata?: Record, + ): Promise { + const c = this.client; + if (!c || c.getStatus() !== "connected") { + return { resources: [], nextCursor: undefined }; + } + const result = await c.listResources(cursor, metadata); + if (cursor === undefined) { + this.resources = [...result.resources]; + } else { + this.resources = [...this.resources, ...result.resources]; + } + this.dispatchTypedEvent("resourcesChange", this.resources); + return { + resources: result.resources, + nextCursor: result.nextCursor, + }; + } + + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.resources = []; + } +} diff --git a/core/mcp/state/pagedToolsState.ts b/core/mcp/state/pagedToolsState.ts new file mode 100644 index 000000000..68ee0b1ba --- /dev/null +++ b/core/mcp/state/pagedToolsState.ts @@ -0,0 +1,93 @@ +/** + * PagedToolsState: holds an aggregated list of tools loaded via loadPage(cursor). + * Does not load on connect; caller drives loading. Clears on disconnect. + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface PagedToolsStateEventMap { + toolsChange: Tool[]; +} + +/** + * Result of loading one page of tools. + */ +export interface LoadPageResult { + /** Tools in this page. */ + tools: Tool[]; + /** Cursor for the next page, if any. */ + nextCursor?: string; +} + +/** + * State manager that holds the union of tools loaded via loadPage(). + * Does not load on connect; does not subscribe to toolsListChanged. + * Subscribes only to statusChange to clear tools on disconnect. + */ +export class PagedToolsState extends TypedEventTarget { + private tools: Tool[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + + constructor(client: InspectorClient) { + super(); + this.client = client; + const onStatusChange = (): void => { + if (this.client?.getStatus() === "disconnected") { + this.tools = []; + this.dispatchTypedEvent("toolsChange", []); + } + }; + this.client.addEventListener("statusChange", onStatusChange); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("statusChange", onStatusChange); + } + this.client = null; + }; + } + + getTools(): Tool[] { + return [...this.tools]; + } + + /** + * Clear the aggregated list and dispatch toolsChange with []. + * Caller can call loadPage() again to reload from the first page. + */ + clear(): void { + this.tools = []; + this.dispatchTypedEvent("toolsChange", this.tools); + } + + /** + * Load one page of tools. Pass no cursor for the first page, then pass + * the returned nextCursor for subsequent pages. Appends (or sets for first + * page) the page into the aggregated list and dispatches toolsChange. + */ + async loadPage(cursor?: string): Promise { + const c = this.client; + if (!c || c.getStatus() !== "connected") { + return { tools: [], nextCursor: undefined }; + } + const result = await c.listTools(cursor, undefined); + if (cursor === undefined) { + this.tools = [...result.tools]; + } else { + this.tools = [...this.tools, ...result.tools]; + } + this.dispatchTypedEvent("toolsChange", this.tools); + return { tools: result.tools, nextCursor: result.nextCursor }; + } + + /** + * Stop listening to the client and clear state. Call when switching clients. + */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.tools = []; + } +} diff --git a/core/mcp/state/stderrLogState.ts b/core/mcp/state/stderrLogState.ts new file mode 100644 index 000000000..fe50c8c19 --- /dev/null +++ b/core/mcp/state/stderrLogState.ts @@ -0,0 +1,86 @@ +/** + * StderrLogState: holds stderr log, subscribes to protocol "stderrLog" events. + * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + * Protocol emits only per-entry "stderrLog" (with payload); this manager owns the list + * and emits stderrLog + stderrLogsChange on append, stderrLogsChange on clear. + * Mirrors InspectorClient: maxStderrLogEvents trim, getStderrLogs(), clearStderrLogs(). + * Does not clear on connect/disconnect (client does not clear stderr - they persist across reconnects). + */ + +import type { InspectorClient } from "../inspectorClient.js"; +import type { StderrLogEntry } from "../types.js"; +import { TypedEventTarget } from "../typedEventTarget.js"; + +export interface StderrLogStateEventMap { + stderrLog: StderrLogEntry; + stderrLogsChange: StderrLogEntry[]; +} + +export interface StderrLogStateOptions { + /** + * Maximum number of stderr log entries to store (0 = unlimited, not recommended). + * When exceeded, oldest entries are dropped. Default 1000, matching InspectorClient. + */ + maxStderrLogEvents?: number; +} + +/** + * State manager that holds the stderr log. Subscribes to the protocol's "stderrLog" + * event (per-entry with payload); appends to its list (trimming to maxStderrLogEvents when set), + * then dispatches "stderrLog" (payload) and "stderrLogsChange" (full list). + * getStderrLogs() and clearStderrLogs() match InspectorClient API. + * Does not clear on connect or disconnect: pre-connect and post-connect entries both remain. + */ +export class StderrLogState extends TypedEventTarget { + private stderrLogs: StderrLogEntry[] = []; + private client: InspectorClient | null = null; + private unsubscribe: (() => void) | null = null; + private readonly maxStderrLogEvents: number; + + constructor(client: InspectorClient, options: StderrLogStateOptions = {}) { + super(); + this.maxStderrLogEvents = options.maxStderrLogEvents ?? 1000; + this.client = client; + const onStderrLog = (event: Event): void => { + const entry = (event as CustomEvent).detail; + if ( + this.maxStderrLogEvents > 0 && + this.stderrLogs.length >= this.maxStderrLogEvents + ) { + this.stderrLogs.shift(); + } + this.stderrLogs.push(entry); + this.dispatchTypedEvent("stderrLog", entry); + this.dispatchTypedEvent("stderrLogsChange", this.getStderrLogs()); + }; + this.client.addEventListener("stderrLog", onStderrLog); + this.unsubscribe = () => { + if (this.client) { + this.client.removeEventListener("stderrLog", onStderrLog); + } + this.client = null; + }; + } + + getStderrLogs(): StderrLogEntry[] { + return [...this.stderrLogs]; + } + + /** + * Clear all stderr log entries. Dispatches stderrLogsChange only if the list was non-empty. + */ + clearStderrLogs(): void { + if (this.stderrLogs.length === 0) return; + this.stderrLogs = []; + this.dispatchTypedEvent("stderrLogsChange", []); + } + + /** + * Stop listening to the client and clear state. Call when switching clients. + */ + destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.stderrLogs = []; + } +} diff --git a/core/mcp/taskNotificationSchemas.ts b/core/mcp/taskNotificationSchemas.ts new file mode 100644 index 000000000..5b877487c --- /dev/null +++ b/core/mcp/taskNotificationSchemas.ts @@ -0,0 +1,23 @@ +/** + * Notification schema for notifications/tasks/list_changed (server → client). + * + * The SDK exports list_changed schemas for resources, prompts, tools, and roots, and + * TaskStatusNotificationSchema for notifications/tasks/status, but no schema for + * notifications/tasks/list_changed. Mainline (v1) does not register a schema for it + * either: they use client.fallbackNotificationHandler so any unmatched notification + * (including tasks/list_changed) is passed to onNotification, and App branches on + * notification.method === "notifications/tasks/list_changed". We register specific + * handlers only (no fallback), so we define this schema to handle the notification. + * + * List-changed notifications have no defined params (they are signals to refetch); + * the SDK uses params: NotificationsParamsSchema.optional() for other list_changed + * types (which is private, so we can't import it). We accept optional params only + * so the notification parses; we do not use any params in the handler. + */ +import { NotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod/v4"; + +export const TasksListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal("notifications/tasks/list_changed"), + params: z.record(z.string(), z.unknown()).optional(), +}); diff --git a/core/mcp/typedEventTarget.ts b/core/mcp/typedEventTarget.ts new file mode 100644 index 000000000..1ec77d9bb --- /dev/null +++ b/core/mcp/typedEventTarget.ts @@ -0,0 +1,85 @@ +/** + * Generic type-safe EventTarget for any domain (InspectorClient, state managers, etc.). + * Extends EventTarget so instances are valid EventTargets; uses overloads to preserve + * base-class assignability while providing typed addEventListener/removeEventListener. + */ + +/** + * Typed event class that extends CustomEvent with type-safe detail. + * For void events, detail is undefined. + */ +export class TypedEventGeneric< + EventMap extends object, + K extends keyof EventMap, +> extends CustomEvent { + constructor(type: K, detail?: EventMap[K]) { + super(type as string, { detail }); + } +} + +/** + * Type-safe EventTarget parameterized by an event map (event name → detail type). + * Extends EventTarget so instances are assignable to EventTarget. Uses the same + * overload pattern as the DOM: typed overloads for our API plus a base-compatible + * implementation so the subclass remains assignable to EventTarget. + */ +export class TypedEventTarget extends EventTarget { + dispatchTypedEvent( + type: K, + ...args: EventMap[K] extends void ? [] : [detail: EventMap[K]] + ): void { + const detail = + (args[0] as EventMap[K] | undefined) ?? (undefined as EventMap[K]); + this.dispatchEvent(new TypedEventGeneric(type, detail)); + } + + addEventListener( + type: K, + listener: (event: TypedEventGeneric) => void, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: + | ((event: TypedEventGeneric) => void) + | EventListenerOrEventListenerObject + | null, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener( + type, + listener as EventListenerOrEventListenerObject | null, + options, + ); + } + + removeEventListener( + type: K, + listener: (event: TypedEventGeneric) => void, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: + | ((event: TypedEventGeneric) => void) + | EventListenerOrEventListenerObject + | null, + options?: boolean | EventListenerOptions, + ): void { + super.removeEventListener( + type, + listener as EventListenerOrEventListenerObject | null, + options, + ); + } +} diff --git a/core/mcp/types.ts b/core/mcp/types.ts new file mode 100644 index 000000000..169db37b3 --- /dev/null +++ b/core/mcp/types.ts @@ -0,0 +1,416 @@ +// Stdio transport config +export interface StdioServerConfig { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + cwd?: string; +} + +// SSE transport config +export interface SseServerConfig { + type: "sse"; + url: string; + headers?: Record; + eventSourceInit?: Record; + requestInit?: Record; +} + +// StreamableHTTP transport config +export interface StreamableHttpServerConfig { + type: "streamable-http"; + url: string; + headers?: Record; + requestInit?: Record; +} + +export type MCPServerConfig = + | StdioServerConfig + | SseServerConfig + | StreamableHttpServerConfig; + +export type ServerType = "stdio" | "sse" | "streamable-http"; + +export interface MCPConfig { + mcpServers: Record; +} + +export type ConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface StderrLogEntry { + timestamp: Date; + message: string; +} + +import type { + ServerCapabilities, + Implementation, + Resource, + Prompt, + Tool, + JSONRPCRequest, + JSONRPCNotification, + JSONRPCResultResponse, + JSONRPCErrorResponse, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface MessageEntry { + id: string; + timestamp: Date; + direction: "request" | "response" | "notification"; + message: + | JSONRPCRequest + | JSONRPCNotification + | JSONRPCResultResponse + | JSONRPCErrorResponse; + response?: JSONRPCResultResponse | JSONRPCErrorResponse; + duration?: number; // Time between request and response in ms +} + +export type FetchRequestCategory = "auth" | "transport"; + +export interface FetchRequestEntry { + id: string; + timestamp: Date; + method: string; + url: string; + requestHeaders: Record; + requestBody?: string; + responseStatus?: number; + responseStatusText?: string; + responseHeaders?: Record; + responseBody?: string; + duration?: number; // Time between request and response in ms + error?: string; + /** Distinguishes OAuth/auth fetches from MCP transport fetches */ + category: FetchRequestCategory; +} + +/** Entry shape from createFetchTracker before category is added by the caller */ +export type FetchRequestEntryBase = Omit; + +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +export interface CreateTransportOptions { + /** + * Optional fetch function. When provided, used as the base for transport HTTP requests + * (SSE, streamable-http). Enables proxy fetch in browser (CORS bypass). + */ + fetchFn?: typeof fetch; + + /** + * Optional callback to handle stderr logs from stdio transports + */ + onStderr?: (entry: StderrLogEntry) => void; + + /** + * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) + */ + pipeStderr?: boolean; + + /** + * Optional callback to track HTTP fetch requests (for SSE and streamable-http transports). + * Receives entries without category; caller adds category when storing. + */ + onFetchRequest?: (entry: FetchRequestEntryBase) => void; + + /** + * Optional OAuth client provider for Bearer authentication (SSE, streamable-http). + * When set, the SDK injects tokens and handles 401 via the provider. + */ + authProvider?: OAuthClientProvider; +} + +export interface CreateTransportResult { + transport: Transport; +} + +/** + * Factory that creates a client transport for an MCP server configuration. + * Required by InspectorClient; caller provides the implementation for their + * environment (e.g. createTransport for Node, RemoteClientTransport factory for browser). + */ +export type CreateTransport = ( + config: MCPServerConfig, + options: CreateTransportOptions, +) => CreateTransportResult; + +export interface ServerState { + status: ConnectionStatus; + error: string | null; + capabilities?: ServerCapabilities; + serverInfo?: Implementation; + instructions?: string; + resources: Resource[]; + prompts: Prompt[]; + tools: Tool[]; + stderrLogs: StderrLogEntry[]; +} + +import type { + ReadResourceResult, + GetPromptResult, + CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import type { JsonValue } from "../json/jsonUtils.js"; + +/** + * Represents a complete resource read invocation, including request parameters, + * response, and metadata. This object is returned from InspectorClient.readResource() + * and cached for later retrieval. + */ +export interface ResourceReadInvocation { + result: ReadResourceResult; // The full SDK response object + timestamp: Date; // When the call was made + uri: string; // The URI that was read (request parameter) + metadata?: Record; // Optional metadata that was passed +} + +/** + * Represents a complete resource template read invocation, including request parameters, + * response, and metadata. This object is returned from InspectorClient.readResourceFromTemplate() + * and cached for later retrieval. + */ +export interface ResourceTemplateReadInvocation { + uriTemplate: string; // The URI template string (unique ID) + expandedUri: string; // The expanded URI after template expansion + result: ReadResourceResult; // The full SDK response object + timestamp: Date; // When the call was made + params: Record; // The parameters used to expand the template (request parameters) + metadata?: Record; // Optional metadata that was passed +} + +/** + * Represents a complete prompt get invocation, including request parameters, + * response, and metadata. This object is returned from InspectorClient.getPrompt() + * and cached for later retrieval. + */ +export interface PromptGetInvocation { + result: GetPromptResult; // The full SDK response object + timestamp: Date; // When the call was made + name: string; // The prompt name (request parameter) + params?: Record; // The parameters used when fetching the prompt (request parameters) + metadata?: Record; // Optional metadata that was passed +} + +/** + * Represents a complete tool call invocation, including request parameters, + * response, and metadata. This object is returned from InspectorClient.callTool() + * and cached for later retrieval. + */ +export interface ToolCallInvocation { + toolName: string; // The tool that was called (request parameter) + params: Record; // The arguments passed to the tool (request parameters) + result: CallToolResult | null; // The full SDK response object (null on error) + timestamp: Date; // When the call was made + success: boolean; // true if call succeeded, false if it threw + error?: string; // Error message if success === false + metadata?: Record; // Optional metadata that was passed +} + +// InspectorClient constructor and environment types +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { LoggingLevel, Root } from "@modelcontextprotocol/sdk/types.js"; +import type pino from "pino"; +import type { + OAuthNavigation, + RedirectUrlProvider, +} from "../auth/providers.js"; +import type { OAuthStorage } from "../auth/storage.js"; + +/** + * Type for the client-like object passed to AppRenderer / @mcp-ui. + * Structurally compatible with the MCP SDK Client but denotes the app-renderer + * proxy, not the raw client. Use this type when passing the client to the Apps tab. + */ +export type AppRendererClient = Client; + +/** + * Consolidated environment interface that defines all environment-specific seams. + * Each environment (Node, browser, tests) provides a complete implementation bundle. + */ +export interface InspectorClientEnvironment { + /** + * Factory that creates a client transport for the given server config. + * Required. Environment provides the implementation: + * - Node: createTransportNode + * - Browser: createRemoteTransport + */ + transport: CreateTransport; + + /** + * Optional fetch function for HTTP requests (OAuth discovery/token exchange and + * MCP transport). When provided, used for both auth and transport to bypass CORS. + * - Node: undefined (uses global fetch) + * - Browser: createRemoteFetch + */ + fetch?: typeof fetch; + + /** + * Optional logger for InspectorClient events (transport, OAuth, etc.). + * - Node: pino file logger + * - Browser: createRemoteLogger + */ + logger?: pino.Logger; + + /** + * OAuth environment components + */ + oauth?: { + /** + * OAuth storage implementation + * - Node: NodeOAuthStorage (file-based) + * - Browser: BrowserOAuthStorage (sessionStorage) or RemoteOAuthStorage (shared state) + */ + storage?: OAuthStorage; + + /** + * Navigation handler for redirecting users to authorization URLs + * - Node: ConsoleNavigation + * - Browser: BrowserNavigation + */ + navigation?: OAuthNavigation; + + /** + * Redirect URL provider + * - Node: from OAuth callback server + * - Browser: from window.location or callback route + */ + redirectUrlProvider?: RedirectUrlProvider; + }; +} + +export interface InspectorClientOptions { + /** + * Environment-specific implementations (transport, fetch, logger, OAuth components) + */ + environment: InspectorClientEnvironment; + + /** + * Client identity (name and version) + */ + clientIdentity?: { + name: string; + version: string; + }; + /** + * Whether to pipe stderr for stdio transports (default: true for TUI, false for CLI) + */ + pipeStderr?: boolean; + + /** + * Initial logging level to set after connection (if server supports logging) + * If not provided, logging level will not be set automatically + */ + initialLoggingLevel?: LoggingLevel; + + /** + * Whether to advertise sampling capability (default: true) + */ + sample?: boolean; + + /** + * Elicitation capability configuration + * - `true` - support form-based elicitation only (default, for backward compatibility) + * - `{ form: true }` - support form-based elicitation only + * - `{ url: true }` - support URL-based elicitation only + * - `{ form: true, url: true }` - support both form and URL-based elicitation + * - `false` or `undefined` - no elicitation support + */ + elicit?: + | boolean + | { + form?: boolean; + url?: boolean; + }; + + /** + * Initial roots to configure. If provided (even if empty array), the client will + * advertise roots capability and handle roots/list requests from the server. + */ + roots?: Root[]; + + /** + * Whether to enable listChanged notification handlers (default: true) + * If enabled, InspectorClient will subscribe to list_changed notifications and fire + * corresponding events (toolsListChanged, resourcesListChanged, promptsListChanged). + */ + listChangedNotifications?: { + tools?: boolean; // default: true + resources?: boolean; // default: true + prompts?: boolean; // default: true + }; + + /** + * Whether to enable progress notification handling (default: true) + * If enabled, InspectorClient will register a handler for progress notifications and dispatch progressNotification events + */ + progress?: boolean; // default: true + + /** + * If true, receiving a progress notification resets the request timeout (default: true). + * Only applies to requests that can receive progress. Set to false for strict timeout caps. + */ + resetTimeoutOnProgress?: boolean; + + /** + * Per-request timeout in milliseconds. If not set, the SDK default (60_000) is used. + */ + timeout?: number; + + /** + * OAuth configuration (client credentials, scope, etc.) + * Note: OAuth environment components (storage, navigation, redirectUrlProvider) + * are in environment.oauth, but clientId/clientSecret/scope are config. + */ + oauth?: { + /** + * Preregistered client ID (optional, will use DCR if not provided) + * If clientMetadataUrl is provided, this is ignored (CIMD mode) + */ + clientId?: string; + + /** + * Preregistered client secret (optional, only if client requires secret) + * If clientMetadataUrl is provided, this is ignored (CIMD mode) + */ + clientSecret?: string; + + /** + * Client metadata URL for CIMD (Client ID Metadata Documents) mode + * If provided, enables URL-based client IDs (SEP-991) + * The URL becomes the client_id, and the authorization server fetches it to discover client metadata + */ + clientMetadataUrl?: string; + + /** + * OAuth scope (optional, will be discovered if not provided) + */ + scope?: string; + }; + + /** + * Optional session ID. If not provided, will be extracted from OAuth state + * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. + */ + sessionId?: string; + + /** + * When true, advertise receiver-task capability and handle task-augmented + * sampling/createMessage and elicit; register tasks/list, tasks/get, + * tasks/result, tasks/cancel handlers. Default false. + */ + receiverTasks?: boolean; + + /** + * TTL in ms for receiver tasks when server sends params.task without ttl. + * Only used when receiverTasks is true. If a function, called at task creation. + * Default 60_000 when omitted. + */ + receiverTaskTtlMs?: number | (() => number); +} diff --git a/core/package.json b/core/package.json new file mode 100644 index 000000000..7e0bc141d --- /dev/null +++ b/core/package.json @@ -0,0 +1,62 @@ +{ + "name": "@modelcontextprotocol/inspector-core", + "version": "0.20.0", + "private": true, + "type": "module", + "main": "./build/mcp/index.js", + "types": "./build/mcp/index.d.ts", + "exports": { + ".": "./build/mcp/index.js", + "./mcp/*": "./build/mcp/*", + "./auth": "./build/auth/index.js", + "./auth/*": "./build/auth/*", + "./react/*": "./build/react/*", + "./json/*": "./build/json/*", + "./auth/node": "./build/auth/node/index.js", + "./auth/node/*": "./build/auth/node/*", + "./mcp/node": "./build/mcp/node/index.js", + "./mcp/node/*": "./build/mcp/node/*", + "./auth/browser": "./build/auth/browser/index.js", + "./auth/browser/*": "./build/auth/browser/*", + "./logging": "./build/logging/index.js", + "./logging/*": "./build/logging/*", + "./mcp/remote": "./build/mcp/remote/index.js", + "./mcp/remote/*": "./build/mcp/remote/*", + "./mcp/remote/node": "./build/mcp/remote/node/index.js", + "./mcp/remote/node/*": "./build/mcp/remote/node/*" + }, + "files": [ + "build" + ], + "scripts": { + "build": "tsc && cp package.json build/package.json", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "pino": "^9.6.0", + "react": "^18.3.1" + }, + "dependencies": { + "atomically": "^2.1.1", + "hono": "^4.6.0", + "zustand": "^5.0.10" + }, + "devDependencies": { + "@hono/node-server": "^1.19.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "zod": "^3.25 || ^4.0", + "@types/react": "^18.3.23", + "pino": "^9.6.0", + "typescript": "^5.4.2", + "vitest": "^4.0.17", + "@testing-library/react": "^16.0.0", + "react": "^18.3.1", + "jsdom": "^25.0.0", + "express": "^5.1.0", + "@types/express": "^5.0.0", + "@modelcontextprotocol/inspector-test-server": "*" + } +} diff --git a/core/react/useFetchRequestLog.ts b/core/react/useFetchRequestLog.ts new file mode 100644 index 000000000..53bfe83a5 --- /dev/null +++ b/core/react/useFetchRequestLog.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from "react"; +import type { FetchRequestEntry } from "../mcp/types.js"; +import type { FetchRequestLogState } from "../mcp/state/fetchRequestLogState.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { FetchRequestLogStateEventMap } from "../mcp/state/fetchRequestLogState.js"; + +export interface UseFetchRequestLogResult { + fetchRequests: FetchRequestEntry[]; +} + +/** + * React hook that subscribes to FetchRequestLogState and returns the fetch request list. + */ +export function useFetchRequestLog( + fetchRequestLogState: FetchRequestLogState | null, +): UseFetchRequestLogResult { + const [fetchRequests, setFetchRequests] = useState( + fetchRequestLogState?.getFetchRequests() ?? [], + ); + + useEffect(() => { + if (!fetchRequestLogState) { + setFetchRequests([]); + return; + } + setFetchRequests(fetchRequestLogState.getFetchRequests()); + const onFetchRequestsChange = ( + event: TypedEventGeneric< + FetchRequestLogStateEventMap, + "fetchRequestsChange" + >, + ) => { + setFetchRequests(event.detail); + }; + fetchRequestLogState.addEventListener( + "fetchRequestsChange", + onFetchRequestsChange, + ); + return () => { + fetchRequestLogState.removeEventListener( + "fetchRequestsChange", + onFetchRequestsChange, + ); + }; + }, [fetchRequestLogState]); + + return { fetchRequests }; +} diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts new file mode 100644 index 000000000..6d34870c8 --- /dev/null +++ b/core/react/useInspectorClient.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback } from "react"; +import { InspectorClient } from "../mcp/index.js"; +import type { TypedEvent } from "../mcp/inspectorClientEventTarget.js"; +import type { ConnectionStatus } from "../mcp/index.js"; +import type { AppRendererClient } from "../mcp/index.js"; +import type { + ServerCapabilities, + Implementation, +} from "@modelcontextprotocol/sdk/types.js"; + +export interface UseInspectorClientResult { + status: ConnectionStatus; + capabilities?: ServerCapabilities; + serverInfo?: Implementation; + instructions?: string; + appRendererClient: AppRendererClient | null; + connect: () => Promise; + disconnect: () => Promise; +} + +/** + * React hook that subscribes to InspectorClient events and provides reactive state + */ +export function useInspectorClient( + inspectorClient: InspectorClient | null, +): UseInspectorClientResult { + const [status, setStatus] = useState( + inspectorClient?.getStatus() ?? "disconnected", + ); + const [capabilities, setCapabilities] = useState< + ServerCapabilities | undefined + >(inspectorClient?.getCapabilities()); + const [serverInfo, setServerInfo] = useState( + inspectorClient?.getServerInfo(), + ); + const [instructions, setInstructions] = useState( + inspectorClient?.getInstructions(), + ); + + // Subscribe to InspectorClient events (log lists are provided by log state managers + useMessageLog, useFetchRequestLog, useStderrLog) + useEffect(() => { + if (!inspectorClient) { + setStatus("disconnected"); + setCapabilities(undefined); + setServerInfo(undefined); + setInstructions(undefined); + return; + } + + setStatus(inspectorClient.getStatus()); + setCapabilities(inspectorClient.getCapabilities()); + setServerInfo(inspectorClient.getServerInfo()); + setInstructions(inspectorClient.getInstructions()); + + const onStatusChange = (event: TypedEvent<"statusChange">) => { + setStatus(event.detail); + }; + const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => { + setCapabilities(event.detail); + }; + const onServerInfoChange = (event: TypedEvent<"serverInfoChange">) => { + setServerInfo(event.detail); + }; + const onInstructionsChange = (event: TypedEvent<"instructionsChange">) => { + setInstructions(event.detail); + }; + + inspectorClient.addEventListener("statusChange", onStatusChange); + inspectorClient.addEventListener( + "capabilitiesChange", + onCapabilitiesChange, + ); + inspectorClient.addEventListener("serverInfoChange", onServerInfoChange); + inspectorClient.addEventListener( + "instructionsChange", + onInstructionsChange, + ); + + return () => { + inspectorClient.removeEventListener("statusChange", onStatusChange); + inspectorClient.removeEventListener( + "capabilitiesChange", + onCapabilitiesChange, + ); + inspectorClient.removeEventListener( + "serverInfoChange", + onServerInfoChange, + ); + inspectorClient.removeEventListener( + "instructionsChange", + onInstructionsChange, + ); + }; + }, [inspectorClient]); + + const connect = useCallback(async () => { + if (!inspectorClient) return; + await inspectorClient.connect(); + }, [inspectorClient]); + + const disconnect = useCallback(async () => { + if (!inspectorClient) return; + await inspectorClient.disconnect(); + }, [inspectorClient]); + + return { + status, + capabilities, + serverInfo, + instructions, + appRendererClient: inspectorClient?.getAppRendererClient() ?? null, + connect, + disconnect, + }; +} diff --git a/core/react/useManagedPrompts.ts b/core/react/useManagedPrompts.ts new file mode 100644 index 000000000..cda7bb982 --- /dev/null +++ b/core/react/useManagedPrompts.ts @@ -0,0 +1,49 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { ManagedPromptsState } from "../mcp/state/managedPromptsState.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { ManagedPromptsStateEventMap } from "../mcp/state/managedPromptsState.js"; + +export interface UseManagedPromptsResult { + prompts: Prompt[]; + refresh: () => Promise; +} + +/** + * React hook that subscribes to ManagedPromptsState and returns prompts + refresh. + */ +export function useManagedPrompts( + client: InspectorClient | null, + managedPromptsState: ManagedPromptsState | null, +): UseManagedPromptsResult { + const [prompts, setPrompts] = useState( + managedPromptsState?.getPrompts() ?? [], + ); + + useEffect(() => { + if (!managedPromptsState) { + setPrompts([]); + return; + } + setPrompts(managedPromptsState.getPrompts()); + const onPromptsChange = ( + event: TypedEventGeneric, + ) => { + setPrompts(event.detail); + }; + managedPromptsState.addEventListener("promptsChange", onPromptsChange); + return () => { + managedPromptsState.removeEventListener("promptsChange", onPromptsChange); + }; + }, [managedPromptsState]); + + const refresh = useCallback(async (): Promise => { + if (!managedPromptsState || !client) return []; + const next = await managedPromptsState.refresh(); + setPrompts(next); + return next; + }, [client, managedPromptsState]); + + return { prompts, refresh }; +} diff --git a/core/react/useManagedRequestorTasks.ts b/core/react/useManagedRequestorTasks.ts new file mode 100644 index 000000000..8aade6245 --- /dev/null +++ b/core/react/useManagedRequestorTasks.ts @@ -0,0 +1,55 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { ManagedRequestorTasksState } from "../mcp/state/managedRequestorTasksState.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { ManagedRequestorTasksStateEventMap } from "../mcp/state/managedRequestorTasksState.js"; + +export interface UseManagedRequestorTasksResult { + tasks: Task[]; + refresh: () => Promise; +} + +/** + * React hook that subscribes to ManagedRequestorTasksState and returns tasks + refresh. + */ +export function useManagedRequestorTasks( + client: InspectorClient | null, + managedRequestorTasksState: ManagedRequestorTasksState | null, +): UseManagedRequestorTasksResult { + const [tasks, setTasks] = useState( + managedRequestorTasksState?.getTasks() ?? [], + ); + + useEffect(() => { + if (!managedRequestorTasksState) { + setTasks([]); + return; + } + setTasks(managedRequestorTasksState.getTasks()); + const onTasksChange = ( + event: TypedEventGeneric< + ManagedRequestorTasksStateEventMap, + "tasksChange" + >, + ) => { + setTasks(event.detail); + }; + managedRequestorTasksState.addEventListener("tasksChange", onTasksChange); + return () => { + managedRequestorTasksState.removeEventListener( + "tasksChange", + onTasksChange, + ); + }; + }, [managedRequestorTasksState]); + + const refresh = useCallback(async (): Promise => { + if (!managedRequestorTasksState || !client) return []; + const next = await managedRequestorTasksState.refresh(); + setTasks(next); + return next; + }, [client, managedRequestorTasksState]); + + return { tasks, refresh }; +} diff --git a/core/react/useManagedResourceTemplates.ts b/core/react/useManagedResourceTemplates.ts new file mode 100644 index 000000000..46a243fd7 --- /dev/null +++ b/core/react/useManagedResourceTemplates.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { ManagedResourceTemplatesState } from "../mcp/state/managedResourceTemplatesState.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { ManagedResourceTemplatesStateEventMap } from "../mcp/state/managedResourceTemplatesState.js"; + +export interface UseManagedResourceTemplatesResult { + resourceTemplates: ResourceTemplate[]; + refresh: () => Promise; +} + +/** + * React hook that subscribes to ManagedResourceTemplatesState and returns resourceTemplates + refresh. + */ +export function useManagedResourceTemplates( + client: InspectorClient | null, + managedResourceTemplatesState: ManagedResourceTemplatesState | null, +): UseManagedResourceTemplatesResult { + const [resourceTemplates, setResourceTemplates] = useState< + ResourceTemplate[] + >(managedResourceTemplatesState?.getResourceTemplates() ?? []); + + useEffect(() => { + if (!managedResourceTemplatesState) { + setResourceTemplates([]); + return; + } + setResourceTemplates(managedResourceTemplatesState.getResourceTemplates()); + const onResourceTemplatesChange = ( + event: TypedEventGeneric< + ManagedResourceTemplatesStateEventMap, + "resourceTemplatesChange" + >, + ) => { + setResourceTemplates(event.detail); + }; + managedResourceTemplatesState.addEventListener( + "resourceTemplatesChange", + onResourceTemplatesChange, + ); + return () => { + managedResourceTemplatesState.removeEventListener( + "resourceTemplatesChange", + onResourceTemplatesChange, + ); + }; + }, [managedResourceTemplatesState]); + + const refresh = useCallback(async (): Promise => { + if (!managedResourceTemplatesState || !client) return []; + const next = await managedResourceTemplatesState.refresh(); + setResourceTemplates(next); + return next; + }, [client, managedResourceTemplatesState]); + + return { resourceTemplates, refresh }; +} diff --git a/core/react/useManagedResources.ts b/core/react/useManagedResources.ts new file mode 100644 index 000000000..59940157c --- /dev/null +++ b/core/react/useManagedResources.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { ManagedResourcesState } from "../mcp/state/managedResourcesState.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { ManagedResourcesStateEventMap } from "../mcp/state/managedResourcesState.js"; + +export interface UseManagedResourcesResult { + resources: Resource[]; + refresh: () => Promise; +} + +/** + * React hook that subscribes to ManagedResourcesState and returns resources + refresh. + */ +export function useManagedResources( + client: InspectorClient | null, + managedResourcesState: ManagedResourcesState | null, +): UseManagedResourcesResult { + const [resources, setResources] = useState( + managedResourcesState?.getResources() ?? [], + ); + + useEffect(() => { + if (!managedResourcesState) { + setResources([]); + return; + } + setResources(managedResourcesState.getResources()); + const onResourcesChange = ( + event: TypedEventGeneric< + ManagedResourcesStateEventMap, + "resourcesChange" + >, + ) => { + setResources(event.detail); + }; + managedResourcesState.addEventListener( + "resourcesChange", + onResourcesChange, + ); + return () => { + managedResourcesState.removeEventListener( + "resourcesChange", + onResourcesChange, + ); + }; + }, [managedResourcesState]); + + const refresh = useCallback(async (): Promise => { + if (!managedResourcesState || !client) return []; + const next = await managedResourcesState.refresh(); + setResources(next); + return next; + }, [client, managedResourcesState]); + + return { resources, refresh }; +} diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts new file mode 100644 index 000000000..df3feb1da --- /dev/null +++ b/core/react/useManagedTools.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { ManagedToolsState } from "../mcp/state/managedToolsState.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js"; + +export interface UseManagedToolsResult { + tools: Tool[]; + refresh: () => Promise; +} + +/** + * React hook that subscribes to ManagedToolsState and returns tools + refresh. + * Pass the same InspectorClient and ManagedToolsState (or create manager inside hook via ref). + */ +export function useManagedTools( + client: InspectorClient | null, + managedToolsState: ManagedToolsState | null, +): UseManagedToolsResult { + const [tools, setTools] = useState( + managedToolsState?.getTools() ?? [], + ); + + useEffect(() => { + if (!managedToolsState) { + setTools([]); + return; + } + setTools(managedToolsState.getTools()); + const onToolsChange = ( + event: TypedEventGeneric, + ) => { + setTools(event.detail); + }; + managedToolsState.addEventListener("toolsChange", onToolsChange); + return () => { + managedToolsState.removeEventListener("toolsChange", onToolsChange); + }; + }, [managedToolsState]); + + const refresh = useCallback(async (): Promise => { + if (!managedToolsState || !client) return []; + const next = await managedToolsState.refresh(); + setTools(next); + return next; + }, [client, managedToolsState]); + + return { tools, refresh }; +} diff --git a/core/react/useMessageLog.ts b/core/react/useMessageLog.ts new file mode 100644 index 000000000..11767be02 --- /dev/null +++ b/core/react/useMessageLog.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from "react"; +import type { MessageEntry } from "../mcp/types.js"; +import type { MessageLogState } from "../mcp/state/messageLogState.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { MessageLogStateEventMap } from "../mcp/state/messageLogState.js"; + +export interface UseMessageLogResult { + messages: MessageEntry[]; +} + +/** + * React hook that subscribes to MessageLogState and returns the message list. + */ +export function useMessageLog( + messageLogState: MessageLogState | null, +): UseMessageLogResult { + const [messages, setMessages] = useState( + messageLogState?.getMessages() ?? [], + ); + + useEffect(() => { + if (!messageLogState) { + setMessages([]); + return; + } + setMessages(messageLogState.getMessages()); + const onMessagesChange = ( + event: TypedEventGeneric, + ) => { + setMessages(event.detail); + }; + messageLogState.addEventListener("messagesChange", onMessagesChange); + return () => { + messageLogState.removeEventListener("messagesChange", onMessagesChange); + }; + }, [messageLogState]); + + return { messages }; +} diff --git a/core/react/usePagedPrompts.ts b/core/react/usePagedPrompts.ts new file mode 100644 index 000000000..9ea45d897 --- /dev/null +++ b/core/react/usePagedPrompts.ts @@ -0,0 +1,68 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { PagedPromptsState } from "../mcp/state/pagedPromptsState.js"; +import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { + PagedPromptsStateEventMap, + LoadPageResult, +} from "../mcp/state/pagedPromptsState.js"; + +export interface UsePagedPromptsResult { + prompts: Prompt[]; + loadPage: ( + cursor?: string, + metadata?: Record, + ) => Promise; + clear: () => void; +} + +/** + * React hook that subscribes to PagedPromptsState and returns prompts + loadPage. + */ +export function usePagedPrompts( + client: InspectorClient | null, + pagedPromptsState: PagedPromptsState | null, +): UsePagedPromptsResult { + const [prompts, setPrompts] = useState( + pagedPromptsState?.getPrompts() ?? [], + ); + + useEffect(() => { + if (!pagedPromptsState) { + setPrompts([]); + return; + } + setPrompts(pagedPromptsState.getPrompts()); + const onPromptsChange = ( + event: TypedEventGeneric, + ) => { + setPrompts(event.detail); + }; + pagedPromptsState.addEventListener("promptsChange", onPromptsChange); + return () => { + pagedPromptsState.removeEventListener("promptsChange", onPromptsChange); + }; + }, [pagedPromptsState]); + + const loadPage = useCallback( + async ( + cursor?: string, + metadata?: Record, + ): Promise => { + if (!pagedPromptsState || !client) { + return { prompts: [], nextCursor: undefined }; + } + const result = await pagedPromptsState.loadPage(cursor, metadata); + setPrompts(pagedPromptsState.getPrompts()); + return result; + }, + [client, pagedPromptsState], + ); + + const clear = useCallback(() => { + pagedPromptsState?.clear(); + }, [pagedPromptsState]); + + return { prompts, loadPage, clear }; +} diff --git a/core/react/usePagedRequestorTasks.ts b/core/react/usePagedRequestorTasks.ts new file mode 100644 index 000000000..d87c1f63e --- /dev/null +++ b/core/react/usePagedRequestorTasks.ts @@ -0,0 +1,73 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { PagedRequestorTasksState } from "../mcp/state/pagedRequestorTasksState.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { + PagedRequestorTasksStateEventMap, + LoadPageResult, +} from "../mcp/state/pagedRequestorTasksState.js"; + +export interface UsePagedRequestorTasksResult { + tasks: Task[]; + loadPage: (cursor?: string) => Promise; + clear: () => void; + nextCursor: string | undefined; +} + +/** + * React hook that subscribes to PagedRequestorTasksState and returns tasks, loadPage, clear, and nextCursor. + */ +export function usePagedRequestorTasks( + client: InspectorClient | null, + pagedRequestorTasksState: PagedRequestorTasksState | null, +): UsePagedRequestorTasksResult { + const [tasks, setTasks] = useState( + pagedRequestorTasksState?.getTasks() ?? [], + ); + const [nextCursor, setNextCursor] = useState( + pagedRequestorTasksState?.getNextCursor?.() ?? undefined, + ); + + useEffect(() => { + if (!pagedRequestorTasksState) { + setTasks([]); + setNextCursor(undefined); + return; + } + setTasks(pagedRequestorTasksState.getTasks()); + setNextCursor(pagedRequestorTasksState.getNextCursor?.() ?? undefined); + const onTasksChange = ( + event: TypedEventGeneric, + ) => { + setTasks(event.detail); + setNextCursor(pagedRequestorTasksState.getNextCursor?.() ?? undefined); + }; + pagedRequestorTasksState.addEventListener("tasksChange", onTasksChange); + return () => { + pagedRequestorTasksState.removeEventListener( + "tasksChange", + onTasksChange, + ); + }; + }, [pagedRequestorTasksState]); + + const loadPage = useCallback( + async (cursor?: string): Promise => { + if (!pagedRequestorTasksState || !client) { + return { tasks: [], nextCursor: undefined }; + } + const result = await pagedRequestorTasksState.loadPage(cursor); + setTasks(pagedRequestorTasksState.getTasks()); + setNextCursor(pagedRequestorTasksState.getNextCursor?.() ?? undefined); + return result; + }, + [client, pagedRequestorTasksState], + ); + + const clear = useCallback(() => { + pagedRequestorTasksState?.clear(); + }, [pagedRequestorTasksState]); + + return { tasks, loadPage, clear, nextCursor }; +} diff --git a/core/react/usePagedResourceTemplates.ts b/core/react/usePagedResourceTemplates.ts new file mode 100644 index 000000000..146fe9511 --- /dev/null +++ b/core/react/usePagedResourceTemplates.ts @@ -0,0 +1,75 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { PagedResourceTemplatesState } from "../mcp/state/pagedResourceTemplatesState.js"; +import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { + PagedResourceTemplatesStateEventMap, + LoadPageResult, +} from "../mcp/state/pagedResourceTemplatesState.js"; + +export interface UsePagedResourceTemplatesResult { + resourceTemplates: ResourceTemplate[]; + loadPage: ( + cursor?: string, + metadata?: Record, + ) => Promise; + clear: () => void; +} + +export function usePagedResourceTemplates( + client: InspectorClient | null, + pagedResourceTemplatesState: PagedResourceTemplatesState | null, +): UsePagedResourceTemplatesResult { + const [resourceTemplates, setResourceTemplates] = useState< + ResourceTemplate[] + >(pagedResourceTemplatesState?.getResourceTemplates() ?? []); + + useEffect(() => { + if (!pagedResourceTemplatesState) { + setResourceTemplates([]); + return; + } + setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates()); + const onResourceTemplatesChange = ( + event: TypedEventGeneric< + PagedResourceTemplatesStateEventMap, + "resourceTemplatesChange" + >, + ) => setResourceTemplates(event.detail); + pagedResourceTemplatesState.addEventListener( + "resourceTemplatesChange", + onResourceTemplatesChange, + ); + return () => { + pagedResourceTemplatesState.removeEventListener( + "resourceTemplatesChange", + onResourceTemplatesChange, + ); + }; + }, [pagedResourceTemplatesState]); + + const loadPage = useCallback( + async ( + cursor?: string, + metadata?: Record, + ): Promise => { + if (!pagedResourceTemplatesState || !client) { + return { resourceTemplates: [], nextCursor: undefined }; + } + const result = await pagedResourceTemplatesState.loadPage( + cursor, + metadata, + ); + setResourceTemplates(pagedResourceTemplatesState.getResourceTemplates()); + return result; + }, + [client, pagedResourceTemplatesState], + ); + + const clear = useCallback(() => { + pagedResourceTemplatesState?.clear(); + }, [pagedResourceTemplatesState]); + + return { resourceTemplates, loadPage, clear }; +} diff --git a/core/react/usePagedResources.ts b/core/react/usePagedResources.ts new file mode 100644 index 000000000..f8cee7232 --- /dev/null +++ b/core/react/usePagedResources.ts @@ -0,0 +1,71 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { PagedResourcesState } from "../mcp/state/pagedResourcesState.js"; +import type { Resource } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { + PagedResourcesStateEventMap, + LoadPageResult, +} from "../mcp/state/pagedResourcesState.js"; + +export interface UsePagedResourcesResult { + resources: Resource[]; + loadPage: ( + cursor?: string, + metadata?: Record, + ) => Promise; + clear: () => void; +} + +/** + * React hook that subscribes to PagedResourcesState and returns resources + loadPage. + */ +export function usePagedResources( + client: InspectorClient | null, + pagedResourcesState: PagedResourcesState | null, +): UsePagedResourcesResult { + const [resources, setResources] = useState( + pagedResourcesState?.getResources() ?? [], + ); + + useEffect(() => { + if (!pagedResourcesState) { + setResources([]); + return; + } + setResources(pagedResourcesState.getResources()); + const onResourcesChange = ( + event: TypedEventGeneric, + ) => { + setResources(event.detail); + }; + pagedResourcesState.addEventListener("resourcesChange", onResourcesChange); + return () => { + pagedResourcesState.removeEventListener( + "resourcesChange", + onResourcesChange, + ); + }; + }, [pagedResourcesState]); + + const loadPage = useCallback( + async ( + cursor?: string, + metadata?: Record, + ): Promise => { + if (!pagedResourcesState || !client) { + return { resources: [], nextCursor: undefined }; + } + const result = await pagedResourcesState.loadPage(cursor, metadata); + setResources(pagedResourcesState.getResources()); + return result; + }, + [client, pagedResourcesState], + ); + + const clear = useCallback(() => { + pagedResourcesState?.clear(); + }, [pagedResourcesState]); + + return { resources, loadPage, clear }; +} diff --git a/core/react/usePagedTools.ts b/core/react/usePagedTools.ts new file mode 100644 index 000000000..a46391c91 --- /dev/null +++ b/core/react/usePagedTools.ts @@ -0,0 +1,60 @@ +import { useState, useEffect, useCallback } from "react"; +import type { InspectorClient } from "../mcp/inspectorClient.js"; +import type { PagedToolsState } from "../mcp/state/pagedToolsState.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { + PagedToolsStateEventMap, + LoadPageResult, +} from "../mcp/state/pagedToolsState.js"; + +export interface UsePagedToolsResult { + tools: Tool[]; + loadPage: (cursor?: string) => Promise; + clear: () => void; +} + +/** + * React hook that subscribes to PagedToolsState and returns tools + loadPage. + */ +export function usePagedTools( + client: InspectorClient | null, + pagedToolsState: PagedToolsState | null, +): UsePagedToolsResult { + const [tools, setTools] = useState(pagedToolsState?.getTools() ?? []); + + useEffect(() => { + if (!pagedToolsState) { + setTools([]); + return; + } + setTools(pagedToolsState.getTools()); + const onToolsChange = ( + event: TypedEventGeneric, + ) => { + setTools(event.detail); + }; + pagedToolsState.addEventListener("toolsChange", onToolsChange); + return () => { + pagedToolsState.removeEventListener("toolsChange", onToolsChange); + }; + }, [pagedToolsState]); + + const loadPage = useCallback( + async (cursor?: string): Promise => { + if (!pagedToolsState || !client) { + return { tools: [], nextCursor: undefined }; + } + const result = await pagedToolsState.loadPage(cursor); + setTools(pagedToolsState.getTools()); + return result; + }, + [client, pagedToolsState], + ); + + const clear = useCallback(() => { + pagedToolsState?.clear(); + }, [pagedToolsState]); + + return { tools, loadPage, clear }; +} diff --git a/core/react/useStderrLog.ts b/core/react/useStderrLog.ts new file mode 100644 index 000000000..d47bd1763 --- /dev/null +++ b/core/react/useStderrLog.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from "react"; +import type { StderrLogEntry } from "../mcp/types.js"; +import type { StderrLogState } from "../mcp/state/stderrLogState.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import type { StderrLogStateEventMap } from "../mcp/state/stderrLogState.js"; + +export interface UseStderrLogResult { + stderrLogs: StderrLogEntry[]; +} + +/** + * React hook that subscribes to StderrLogState and returns the stderr log list. + */ +export function useStderrLog( + stderrLogState: StderrLogState | null, +): UseStderrLogResult { + const [stderrLogs, setStderrLogs] = useState( + stderrLogState?.getStderrLogs() ?? [], + ); + + useEffect(() => { + if (!stderrLogState) { + setStderrLogs([]); + return; + } + setStderrLogs(stderrLogState.getStderrLogs()); + const onStderrLogsChange = ( + event: TypedEventGeneric, + ) => { + setStderrLogs(event.detail); + }; + stderrLogState.addEventListener("stderrLogsChange", onStderrLogsChange); + return () => { + stderrLogState.removeEventListener( + "stderrLogsChange", + onStderrLogsChange, + ); + }; + }, [stderrLogState]); + + return { stderrLogs }; +} diff --git a/core/storage/adapters/file-storage.ts b/core/storage/adapters/file-storage.ts new file mode 100644 index 000000000..5d45c7bd7 --- /dev/null +++ b/core/storage/adapters/file-storage.ts @@ -0,0 +1,27 @@ +/** + * File-based storage adapter for Zustand persist middleware. + * Stores entire store state as JSON in a single file using atomic I/O. + */ + +import { createJSONStorage } from "zustand/middleware"; +import { readStoreFile, writeStoreFile, deleteStoreFile } from "../store-io.js"; + +export interface FileStorageAdapterOptions { + /** Full path to the storage file */ + filePath: string; +} + +/** + * Creates a Zustand storage adapter that reads/writes from a file. + * Conforms to Zustand's StateStorage interface. + */ +export function createFileStorageAdapter( + options: FileStorageAdapterOptions, +): ReturnType { + return createJSONStorage(() => ({ + getItem: async () => readStoreFile(options.filePath), + setItem: async (_name: string, value: string) => + writeStoreFile(options.filePath, value), + removeItem: async () => deleteStoreFile(options.filePath), + })); +} diff --git a/core/storage/adapters/index.ts b/core/storage/adapters/index.ts new file mode 100644 index 000000000..214ba563d --- /dev/null +++ b/core/storage/adapters/index.ts @@ -0,0 +1,10 @@ +/** + * Storage adapters for Zustand persist middleware. + * Provides adapters for file, remote HTTP, and browser storage. + */ + +export { createFileStorageAdapter } from "./file-storage.js"; +export type { FileStorageAdapterOptions } from "./file-storage.js"; + +export { createRemoteStorageAdapter } from "./remote-storage.js"; +export type { RemoteStorageAdapterOptions } from "./remote-storage.js"; diff --git a/core/storage/adapters/remote-storage.ts b/core/storage/adapters/remote-storage.ts new file mode 100644 index 000000000..d143584a5 --- /dev/null +++ b/core/storage/adapters/remote-storage.ts @@ -0,0 +1,94 @@ +/** + * Remote HTTP storage adapter for Zustand persist middleware. + * Stores entire store state via HTTP API (GET/POST/DELETE /api/storage/:storeId). + */ + +import { createJSONStorage } from "zustand/middleware"; + +export interface RemoteStorageAdapterOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + /** Store ID (e.g. "oauth", "inspector-settings") */ + storeId: string; + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + /** Fetch function to use (default: globalThis.fetch) */ + fetchFn?: typeof fetch; +} + +/** + * Creates a Zustand storage adapter that reads/writes via HTTP API. + * Conforms to Zustand's StateStorage interface. + */ +export function createRemoteStorageAdapter( + options: RemoteStorageAdapterOptions, +): ReturnType { + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + + return createJSONStorage(() => ({ + getItem: async (_name: string) => { + const headers: Record = {}; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "GET", + headers, + }); + + if (!res.ok) { + if (res.status === 404) { + return null; + } + throw new Error(`Failed to read store: ${res.status}`); + } + + const store = await res.json(); + // Zustand stores: { state: {...}, version: number } + // API returns the stored blob. If empty, Zustand hasn't initialized yet. + if (Object.keys(store).length === 0) { + return null; // Empty store means not initialized yet + } + // Return the stored Zustand format as string + return JSON.stringify(store); + }, + setItem: async (_name: string, value: string) => { + const headers: Record = { + "Content-Type": "application/json", + }; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + // Zustand gives us the full persisted format as a string + // Store it as-is (the API treats it as an opaque blob) + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "POST", + headers, + body: value, // Already a JSON string from Zustand + }); + + if (!res.ok) { + throw new Error(`Failed to write store: ${res.status}`); + } + }, + removeItem: async (_name: string) => { + const headers: Record = {}; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "DELETE", + headers, + }); + + // 404 is fine (already deleted), but other errors should propagate + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to delete store: ${res.status}`); + } + }, + })); +} diff --git a/core/storage/store-io.ts b/core/storage/store-io.ts new file mode 100644 index 000000000..d0dacbe3f --- /dev/null +++ b/core/storage/store-io.ts @@ -0,0 +1,93 @@ +/** + * Shared storage path resolution, validation, and atomic file I/O. + * Used by the file storage adapter and the remote server's /api/storage routes. + */ + +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { readFile, writeFile } from "atomically"; + +/** + * Default storage directory (~/.mcp-inspector/storage or %USERPROFILE%\.mcp-inspector\storage on Windows). + */ +export function getDefaultStorageDir(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "."; + return path.join(homeDir, ".mcp-inspector", "storage"); +} + +/** + * Path for a store ID under the given storage directory. + * Callers must pass a validated storeId. + */ +export function getStoreFilePath(storageDir: string, storeId: string): string { + return path.join(storageDir, `${storeId}.json`); +} + +/** + * Validate storeId to prevent path traversal. + * Store IDs must be alphanumeric, hyphens, underscores only, and not empty. + */ +export function validateStoreId(storeId: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(storeId) && storeId.length > 0; +} + +/** + * Read store file atomically. Returns null if the file does not exist (ENOENT). + * @throws on other read errors or parse errors (caller may use parseStore on the string). + */ +export async function readStoreFile(filePath: string): Promise { + try { + const data = await readFile(filePath, { encoding: "utf-8" }); + return data; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + return null; + } + throw error; + } +} + +/** + * Write store file atomically (temp file + rename). Ensures parent directory exists. + * Uses mode 0o600 for the file. + */ +export async function writeStoreFile( + filePath: string, + data: string, +): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await writeFile(filePath, data, { + encoding: "utf-8", + mode: 0o600, + }); +} + +/** + * Delete store file. Ignores ENOENT (already deleted). + */ +export async function deleteStoreFile(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + throw error; + } + } +} + +/** + * Serialize store data to JSON string (consistent format for server writes). + */ +export function serializeStore(data: unknown): string { + return JSON.stringify(data, null, 2); +} + +/** + * Parse store JSON string. Use after readStoreFile when returning parsed object. + */ +export function parseStore(raw: string): unknown { + return JSON.parse(raw); +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 000000000..510df526f --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": ".", + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true + }, + "include": [ + "mcp/**/*.ts", + "react/**/*.ts", + "react/**/*.tsx", + "json/**/*.ts", + "auth/**/*.ts", + "storage/**/*.ts", + "logging/**/*.ts", + "package.json" + ], + "exclude": ["node_modules", "build"] +} diff --git a/core/vitest.config.ts b/core/vitest.config.ts new file mode 100644 index 000000000..88307708e --- /dev/null +++ b/core/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["__tests__/**/*.test.{ts,tsx}"], + exclude: ["**/node_modules/**", "**/build/**"], + testTimeout: 30000, // 30 seconds - e2e tests spawn servers + hookTimeout: 30000, // 30 seconds - before/after hooks may start/stop servers + }, +}); diff --git a/docs/environment-isolation.md b/docs/environment-isolation.md new file mode 100644 index 000000000..70e01ec96 --- /dev/null +++ b/docs/environment-isolation.md @@ -0,0 +1,328 @@ +# Environment Isolation + +## Overview + +**Environment isolation** is the design principle of separating pure, portable JavaScript from environment-specific code (Node.js, browser). The shared `InspectorClient` (including OAuth support) runs in Node (CLI, TUI) and in the web UX—a combination of JavaScript in the browser and Node (API endpoints on the UX server or a separate remote server). Environment-specific APIs are isolated behind abstractions or in separate modules (e.g., Node.js's `fs` and `child_process`, or the browser's `sessionStorage`). + +We use the term **seams** for the individual integration points where environment-specific behavior plugs in. Each seam has an abstraction (interface or injection point) and one or more implementations per environment. + +**Dependency consolidation:** All environment-specific dependencies are consolidated into a single `InspectorClientEnvironment` interface. Callers pass one `environment` object; each environment (Node, browser, tests) provides its implementation bundle. This simplifies wiring, clarifies the contract, and keeps optional properties optional. + +## Seams + +These seams provide environment-specific functionality to InspectorClient: + +| Seam | Abstraction | Node Implementation | Browser Implementation (Web App) | +| ---------------------- | ---------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **Transport creation** | `CreateTransport` (required) | `createTransportNode` (creates stdio, SSE, streamable-http) | `createRemoteTransport` (creates `RemoteClientTransport` talking to remote API) | +| **OAuth storage** | `OAuthStorage` | `NodeOAuthStorage` (file-based / Zustand) | `BrowserOAuthStorage` (sessionStorage via Zustand)
`RemoteOAuthStorage` (HTTP API → file-based / Zustand via `/api/storage/oauth`) | +| **OAuth navigation** | `OAuthNavigation` | `CallbackNavigation` (e.g. opens URL via `open`) | `BrowserNavigation` (redirects) | +| **OAuth redirect URL** | `RedirectUrlProvider` | `MutableRedirectUrlProvider` (populated from callback server) | `() => \`${window.location.origin}/oauth/callback\`` (single redirect URL with state parameter) | +| **OAuth HTTP fetch** | Optional `fetchFn` | N/A (Node has no CORS) | `createRemoteFetch` (POSTs to `/api/fetch` for OAuth CORS bypass) | +| **Logging** | Optional `logger` | File-based pino logger | `createRemoteLogger` (POSTs to `/api/log`) | + +**InspectorClientEnvironment structure:** + +All environment-specific dependencies are consolidated into a single `environment` object passed to `InspectorClient`: + +```typescript +interface InspectorClientEnvironment { + transport: CreateTransport; // Required + fetch?: typeof fetch; // Optional, for OAuth and transport HTTP + logger?: pino.Logger; // Optional, for InspectorClient events + oauth?: { + storage?: OAuthStorage; + navigation?: OAuthNavigation; + redirectUrlProvider?: RedirectUrlProvider; + }; +} +``` + +**Node usage example:** + +```typescript +const client = new InspectorClient(config, { + environment: { + transport: createTransportNode, + logger: createFileLogger({ logPath: "/path/to/logs" }), + oauth: { + storage: new NodeOAuthStorage({ dataDir: "/path/to/data" }), + navigation: new ConsoleNavigation(), + redirectUrlProvider: createCallbackServerRedirectUrlProvider(), + }, + }, + oauth: { + clientId: "my-client-id", + clientSecret: "my-secret", + }, +}); +``` + +**Browser usage example (Web App):** + +```typescript +// web/src/lib/adapters/environmentFactory.ts +export function createWebEnvironment( + authToken: string | undefined, + redirectUrlProvider: RedirectUrlProvider, +): InspectorClientEnvironment { + const baseUrl = `${window.location.protocol}//${window.location.host}`; + const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args); + + return { + transport: createRemoteTransport({ + baseUrl, + authToken, + fetchFn, + }), + fetch: createRemoteFetch({ + baseUrl, + authToken, + fetchFn, + }), + logger: createRemoteLogger({ + baseUrl, + authToken, + fetchFn, + }), + oauth: { + storage: new BrowserOAuthStorage(), + navigation: new BrowserNavigation(), + redirectUrlProvider, + }, + }; +} + +// Usage in web app: +const client = new InspectorClient(config, { + environment: createWebEnvironment( + authToken, + () => `${window.location.origin}/oauth/callback`, + ), + oauth: { + clientId: "my-client-id", + clientSecret: "my-secret", // optional + }, +}); +``` + +**Note:** OAuth configuration (clientId, clientSecret, clientMetadataUrl, scope) is separate from environment components and goes in the top-level `oauth` property. The web app uses `BrowserOAuthStorage` (sessionStorage) for browser-only OAuth state. For shared state with Node apps (TUI/CLI), use `RemoteOAuthStorage` instead. + +--- + +## Remote API Server + +The remote API server (`createRemoteApp` in `core/mcp/remote/node/`) is a Hono-based server that hosts all Node-backed endpoints required by browser-based InspectorClient. The server is integrated directly into the Vite dev server (same origin as the web client) and exposes environment-specific functionality as HTTP APIs. The browser uses pure JavaScript wrappers that call these APIs where the Node-specific logic is implemented; InspectorClient remains unaware of whether it is talking to local or remote services. + +**Rationale for Hono** + +Hono is lightweight, framework-agnostic, and supports Node. Using Hono keeps the API surface simple and consistent with the goal of a single server hosting transport, fetch, logging, and storage. The Hono server is integrated into the Vite dev server as middleware, eliminating the need for a separate Express server. This provides same-origin requests (no CORS issues) and simplifies deployment. + +**Integration in Web App** + +- **Dev Mode:** Hono middleware plugin (`honoMiddlewarePlugin`) in `web/vite.config.ts` mounts the Hono app at the root and handles `/api/*` routes +- **Prod Mode:** Standalone Hono server (`web/bin/server.js`) serves both static files and API endpoints +- **Same Origin:** Both dev and prod modes serve from the same origin, eliminating CORS issues +- **Auth Token:** Passed via `MCP_INSPECTOR_API_TOKEN` environment variable (read-only, set by start script). `MCP_PROXY_AUTH_TOKEN` is accepted for backward compatibility. + +**Security** + +All endpoints require authentication via `x-mcp-remote-auth` header (Bearer token format), origin validation, and timing-safe token comparison. The auth token is generated from options, environment variable (`MCP_INSPECTOR_API_TOKEN`, or `MCP_PROXY_AUTH_TOKEN` for backward compatibility), or randomly generated. + +**Endpoints** + +| Endpoint | Purpose | Seam | +| ------------------------------ | ----------------------------------------------------------------- | ----------------- | +| `GET /api/config` | Return initial server config (command, args, transport, URL, env) | Config | +| `POST /api/mcp/connect` | Create session and client transport (stdio, SSE, streamable HTTP) | Remote transports | +| `POST /api/mcp/send` | Forward JSON-RPC message to MCP server | Remote transports | +| `GET /api/mcp/events` | Stream responses and side-channel events (SSE) | Remote transports | +| `POST /api/mcp/disconnect` | Cleanup session | Remote transports | +| `POST /api/fetch` | Remote HTTP requests for OAuth (CORS bypass) | Remote fetch | +| `POST /api/log` | Receive log events from browser for server-side logging | Remote logging | +| `GET /api/storage/:storeId` | Read entire store (generic, e.g. oauth, preferences) | Remote storage | +| `POST /api/storage/:storeId` | Write entire store (generic) | Remote storage | +| `DELETE /api/storage/:storeId` | Delete store (generic) | Remote storage | + +**Out of scope for the API** + +- **OAuth callback** — The web app implements its own `/oauth/callback` route. OAuth redirects hit the web app directly; the callback is not part of the remote API. The web app handles both normal and guided OAuth flows via a single callback endpoint, with mode distinguished by the `state` parameter (`"guided:{random}"` or `"normal:{random}"`). + +--- + +## Remote Infrastructure Details + +These remote seams enable InspectorClient to run in the browser. They fall into two groups: browser integration (new functionality for InspectorClient in the web UX) and code structure (refactoring so the shared package can run in the browser without pulling in Node-only code). + +### Remote Transports (Transport Seam) + +The browser cannot use stdio transports (no `child_process` in browser) and faces CORS/header limitations with direct HTTP connections (e.g. `mcp-session-id` hidden, many servers don't send `Access-Control-Expose-Headers`). + +**Design:** The browser always uses remote transports for all transport types (stdio, SSE, streamable-http). A **remote server** creates real SDK transports in Node and forwards JSON-RPC messages to/from the browser. The browser uses a `RemoteClientTransport` that talks to the remote; it implements the same `Transport` interface as local transports. + +Unlike a proxy that maintains duplicate SDK clients and protocol state, the remote server is **stateless**—it only creates transports and forwards messages. `InspectorClient` runs in the browser and remains the single source of truth for protocol state, message tracking, and server data. This allows the same `InspectorClient` code to work identically in Node (CLI, TUI) and browser, with only the transport factory differing. The remote server runs on the same server as the UX server (Vite dev server or equivalent), though it can run as a separate remote server if needed. + +**Implementation:** `createRemoteTransport` and `RemoteClientTransport` (in `core/mcp/remote/`); `createRemoteApp` (in `core/mcp/remote/node/`). Tests in `core/__tests__/remote-transport.test.ts` cover stdio, SSE, streamable-http. + +**Relevant endpoints:** + +- `POST /api/mcp/connect` — Create session and transport (stdio, SSE, or streamable HTTP). Accepts `{ config: MCPServerConfig, oauthTokens?: {...} }`, creates Node transport, returns `{ sessionId }`. +- `POST /api/mcp/send` — Forward JSON-RPC message to MCP server. Accepts `{ message: JSONRPCMessage, relatedRequestId?: string }`, forwards to transport, returns response. +- `GET /api/mcp/events` — Stream responses (SSE). Multiplexes `message`, `fetch_request`, and `stdio_log` events. +- `POST /api/mcp/disconnect` — Cleanup session. Closes transport and removes session. + +**Design Details** + +The remote server forwards messages only; it holds no SDK `Client` and no protocol state. `InspectorClient` runs in the browser (or Node for CLI/TUI) and remains the single source of truth. The browser always uses remote transports for all transport types; the remote server creates the real transports (stdio, SSE, streamable-http) in Node, so the browser never loads Node-specific transport code or `child_process`. + +When the underlying transport returns 401 (e.g., OAuth required), the remote server preserves the status code and returns HTTP 401 (not 500). `RemoteClientTransport` receives the 401 response and throws an error with the status code preserved, allowing callers to detect authentication failures and trigger OAuth flow manually (consistent with the "authenticate first, then connect" pattern). + +**Event stream and message handlers** + +The remote server multiplexes multiple event types on the SSE stream (`/api/mcp/events`). The browser's `RemoteClientTransport` subscribes to this stream and routes each event to the appropriate handler on the JavaScript side: + +- `event: message` + JSON-RPC data → pass to `transport.onmessage` (protocol messages) +- `event: fetch_request` + `FetchRequestEntry` → call `onFetchRequest` (HTTP request/response tracking for the Requests tab) +- `event: stdio_log` (or `notifications/message`) + stderr payload → call `onStderr` (console output from stdio transports) + +The remote server uses `createFetchTracker` when creating HTTP transports and emits `fetch_request` events when requests complete. For stdio transports, the remote server listens to the child process stderr and emits `stdio_log` (or equivalent) events. The `RemoteClientTransport` implements the same handler interface as local transports, so `InspectorClient` does not need to know whether it is using a local or remote transport. + +**Fetch usage in transports** + +Transports (SSE, streamable-http) use fetch for HTTP and **must support streaming responses** (SSE stream, NDJSON stream). Recording is the **transport creator's responsibility**: wrap the base fetch with createFetchTracker and pass onFetchRequest. The tracking wrapper **does support streaming** (it detects stream content-types and returns the original response without reading the body), so recording does not break streaming. + +- **Node (createTransportNode):** Receives `environment.fetch` from InspectorClient; uses (`environment.fetch ?? globalThis.fetch`) wrapped with createFetchTracker for SSE/streamable-http. If a non-streaming fetch were passed, transport would break; today callers use default or real fetch. + +- **Remote:** The **server** creates the real transport with createTransportNode; it does not receive InspectorClient's `environment.fetch` (that's on the client). So the server uses Node's fetch for the transport. Recording is applied on the server (onFetchRequest → session → fetch_request events to client). The **client**'s RemoteClientTransport uses fetch only for its own HTTP (connect, GET events, send, disconnect). GET /api/mcp/events is SSE, so that fetch must support streaming. **Design decision:** createRemoteTransport does **not** pass InspectorClient's `environment.fetch` to RemoteClientTransport; it uses only the factory's fetchFn (or undefined → globalThis.fetch). So the transport's HTTP always uses a streaming-capable fetch. OAuth can still use a remoted fetch via InspectorClient's `environment.fetch` (effectiveAuthFetch). + +--- + +### Remote Fetch (OAuth CORS Bypass) + +CORS blocks OAuth-related HTTP requests in the browser: discovery, client registration, token exchange, scope discovery, and others. Many authorization servers (e.g., GitHub MCP at `https://api.githubcopilot.com/mcp/`) do not include `Access-Control-Allow-Origin`, causing OAuth flows to fail with: + +``` +Failed to start OAuth flow: Failed to discover OAuth metadata +``` + +**Design:** Pass `fetchFn` in `environment.fetch` to route OAuth-related HTTP requests through the remote server (Node.js), which has no CORS restrictions. InspectorClient uses this fetch for all OAuth operations (discovery, registration, token exchange, etc.). + +**Implementation** + +**InspectorClient:** Accepts optional `environment.fetch` and builds `effectiveAuthFetch` = createFetchTracker(baseFetch, trackRequest) with baseFetch = `environment.fetch ?? fetch`. All OAuth HTTP requests use this effective fetch. + +**`createRemoteFetch`** (in `core/mcp/remote/`): Returns a fetch function that POSTs to `/api/fetch`. The remote server performs the actual HTTP request in Node and returns the response. OAuth responses are JSON (not streaming), so the buffered response from `createRemoteFetch` is sufficient. + +**Relevant endpoint:** + +- `POST /api/fetch` — Accepts `{ url, method?, headers?, body? }`, performs the fetch in Node, returns `{ ok, status, statusText, headers, body }`. Protected by `x-mcp-remote-auth` when `authToken` is set. + +**Client usage:** Pass `createRemoteFetch({ baseUrl, authToken?, fetchFn? })` as `environment.fetch` when in browser. + +**Limitations:** `createRemoteFetch` buffers the response body and cannot stream. It is intended for OAuth (and other non-streaming HTTP) only. Do not use as a general-purpose or transport-level fetchFn where the response might be streaming. + +--- + +### Remote Logging + +InspectorClient accepts optional `environment.logger` (pino `Logger`) for customizable logging. + +The CLI and TUI use a file-based logger. + +Browser clients are unable to write to the server console or the file system, so an optional remote logger may be provided by a browser client user of InspectorClient. The browser client uses a remote logger to forward log events to the server (Node endpoints) where configured loggers may write to the system console, a file-based log, or any other supported Pino log target. + +**Implementation:** `createRemoteLogger` (in `core/mcp/remote/`) returns a pino logger that POSTs to `/api/log` via `pino/browser` transmit. + +**Relevant endpoint:** + +- `POST /api/log` — Receives log events from browser, forwards to file logger when `createRemoteApp({ logger })` is passed. Protected by `x-mcp-remote-auth` when `authToken` is set. + +Tests in `core/__tests__/remote-transport.test.ts` validate the flow. + +--- + +### Remote Storage + +The browser cannot read/write the filesystem directly, so a generic storage API enables shared on-disk state between web app and Node apps (TUI, CLI). + +OAuth tokens and other state need to persist across sessions. In Node (TUI, CLI), we use file-based storage. In the browser, we need a way to share this state with Node apps when the web app runs alongside the Node process hosting the remote API. + +**Design:** A generic storage API that treats stores as opaque JSON blobs (Zustand's persist format). The server stores entire stores as files; clients read/write entire stores via HTTP. + +**Implementation:** + +- **Storage adapters** (`core/storage/adapters/`): Reusable Zustand storage adapters: + - `FileStorageAdapter` — file-based storage for Node (uses `fs/promises`) + - `RemoteStorageAdapter` — HTTP-based storage for browser (uses `/api/storage/:storeId`) +- **OAuth storage implementations**: + - `NodeOAuthStorage` — uses `FileStorageAdapter` with Zustand persist middleware + - `BrowserOAuthStorage` — uses Zustand with `sessionStorage` adapter (browser-only) + - `RemoteOAuthStorage` — uses `RemoteStorageAdapter` for shared state with Node apps + +**Relevant endpoints:** + +- `GET /api/storage/:storeId` — Returns entire store as JSON (empty object `{}` if not found). Protected by `x-mcp-remote-auth` when `authToken` is set. +- `POST /api/storage/:storeId` — Overwrites entire store with provided JSON. Protected by `x-mcp-remote-auth` when `authToken` is set. +- `DELETE /api/storage/:storeId` — Deletes store (idempotent). Protected by `x-mcp-remote-auth` when `authToken` is set. + +**Design Details:** The server treats stores as opaque JSON blobs (Zustand's persist format: `{ state: {...}, version: 0 }`). Store IDs are arbitrary (e.g. `oauth`, `inspector-settings`). All OAuth storage implementations use the same Zustand-backed pattern for consistency. `RemoteOAuthStorage` fetches the store on initialization, implements `OAuthStorage` against the in-memory structure, and persists changes via POST. This enables shared OAuth state when the web app runs alongside the Node process hosting the remote API (e.g. Vite dev server with Hono backend). + +**Web App Usage:** The web app uses `BrowserOAuthStorage` (sessionStorage) for browser-only OAuth state. This provides isolation between browser sessions but does not share state with TUI/CLI. To enable shared OAuth state with Node apps, switch to `RemoteOAuthStorage` in `createWebEnvironment()`. + +**Session persistence across OAuth:** InspectorClient can optionally persist session state (e.g. fetch history) across the OAuth redirect. This is an InspectorClient feature that reuses the same remote storage seam: the web app passes optional `sessionStorage` (e.g. `RemoteInspectorClientStorage`) and `sessionId` (from the OAuth `state` parameter). InspectorClient saves session before navigating to the auth provider and restores it when the client is recreated after the callback. Store IDs follow the pattern `inspector-session-{sessionId}` and use the existing `GET/POST /api/storage/:storeId` endpoints. + +--- + +## Module Organization + +Environment-specific code is under `node` or `browser` subdirectories so the core `auth` and `mcp` modules stay portable. + +### Auth + +| Module | Environment | Contents | Package Export | +| -------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `core/auth/` | Portable | Types, interfaces, base providers, utilities (no `fs`, `window`, or `sessionStorage`). Exports storage interface, `CallbackNavigation`, `ConsoleNavigation`, `BaseOAuthClientProvider`, etc. | `"./auth"` | +| `core/auth/node/` | Node-only | `NodeOAuthStorage`, `createOAuthCallbackServer`, `clearAllOAuthClientState` | `"./auth/node"` | +| `core/auth/browser/` | Browser-only | `BrowserOAuthStorage` (sessionStorage), `BrowserNavigation`, `BrowserOAuthClientProvider` | `"./auth/browser"` | + +**Usage:** Node consumers (TUI, CLI, tests) import from `inspector-core/auth/node`. Browser consumers import from `inspector-core/auth/browser`. Core auth is imported from `inspector-core/auth` only. + +### MCP + +Remote transport code follows the same pattern: portable client in the module root, Node-specific server under `node/`. + +| Module | Environment | Contents | Package Export | +| ----------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `core/mcp/` | Portable | `InspectorClient`, types, `getServerType`, `createFetchTracker`, message tracking, etc. No Node-only APIs. | `"./mcp"` | +| `core/mcp/node/` | Node-only | `loadMcpServersConfig`, `argsToMcpServerConfig`, `createTransportNode` | `"./mcp/node"` | +| `core/mcp/remote/` | Portable | `createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`, `RemoteClientTransport`. Pure TypeScript; runs in browser, Deno, or Node. | `"./mcp/remote"` | +| `core/mcp/remote/node/` | Node-only | Remote server (Hono, spawn, etc.). The server that hosts `/api/mcp/*`, `/api/fetch`, `/api/log`, `/api/storage/*`. | `"./mcp/remote/node"` | + +**Usage:** Node consumers (TUI, CLI) import from `inspector-core/mcp/node` for config loading and transport creation. Web consumers import `createRemoteTransport` from `inspector-core/mcp/remote`; the UX server or a separate remote server runs the remote API from `inspector-core/mcp/remote/node`. + +--- + +## Web App Integration + +The current web client and server functionality has been ported to a new web app (`web/`) that uses `InspectorClient` with remote infrastructure, implementing all functionality previously supported. The separate proxy server/endpoint has been removed. Security via token is retained by implementation of an "API Token" that the local client app uses to access local API endpoints. The `useConnection` hook, auth logic and state machine, etc, have been removed. The new web app is a fairly thin UX wrapper of `InspectorClient` + +**Architecture:** + +- **Environment Factory:** `web/src/lib/adapters/environmentFactory.ts` provides `createWebEnvironment()` that configures: + - `createRemoteTransport()` for all transport types (stdio, SSE, streamable-http) + - `createRemoteFetch()` for OAuth HTTP requests (CORS bypass) + - `createRemoteLogger()` for persistent logging + - `BrowserOAuthStorage` and `BrowserNavigation` for OAuth flows +- **Lazy Client Creation:** Uses `ensureInspectorClient()` helper that validates API token before creating client +- **OAuth Integration:** Single redirect URL (`/oauth/callback`) with mode encoded in state parameter; supports both normal and guided flows +- **Initial Config:** Web app fetches `GET /api/config` (with `x-mcp-remote-auth`) on load; response sets command, args, transport, server URL, and env. Same in dev and prod. + +**Hono Integration:** + +- **Dev Mode:** Hono middleware plugin (`honoMiddlewarePlugin`) in `web/vite.config.ts` mounts the Hono app at the root and handles `/api/*` routes +- **Prod Mode:** Standalone Hono server (`web/bin/server.js`) serves both static files and API endpoints +- **Same Origin:** Both dev and prod modes serve from the same origin, eliminating CORS issues + +**Legacy Client:** + +The legacy web client (`client/`) still exists but is deprecated. It uses `useConnection` with direct SDK transports and a separate Express server. New development should use the `web/` app with `InspectorClient`. diff --git a/docs/flaky-tests-assessment-and-fixes.md b/docs/flaky-tests-assessment-and-fixes.md new file mode 100644 index 000000000..b92440c68 --- /dev/null +++ b/docs/flaky-tests-assessment-and-fixes.md @@ -0,0 +1,273 @@ +# Flaky tests – full assessment and plan + +This document covers all four flaky failure modes with root causes and **specific, vetted** fix suggestions. Plan only; no implementation unless requested. Note that these tests have failed once each over thousands of runs. + +--- + +## 1. Streamable HTTP 404 (inspectorClient + pagedResourceTemplatesState) + +### What the code does + +**Error source (SDK):** `streamableHttp.js` line 364: when a POST gets `!response.ok`, it does `response.text()` and throws `StreamableHTTPError(status, "Error POSTing to endpoint: " + text)`. So the message body we see (HTML or JSON) is the real response body from whoever answered the request. + +**Client:** All POSTs use the same URL: `this._url` (set in the constructor, never changed). So the only way to get a 404 is to hit a host/port that returns 404 for that request. + +**Test server:** `test-server-http.ts` POST `/mcp` returns 404 only when `transports.get(sessionId)` is null, with `res.status(404).json({ error: "Session not found" })` – i.e. **JSON**, not HTML. + +**Conclusion:** + +- **HTML 404** → response did **not** come from our test server (our server always returns JSON for 404). So the request hit another server (port reuse or wrong URL). +- **JSON 404 / "Session not found"** → response came from our test server; the session was missing when the request arrived (session lifecycle or wrong server instance). + +### Root cause 1: Port reuse + request after teardown + +**Mechanism:** + +1. Test A: server A on port P, client A with `url = server.url` (port P). +2. Test A ends; afterEach: `disconnect(A)`, `stop(A)` → port P is freed. +3. Test B: `server.start()` with port 0 → OS assigns P. Server B now listens on P. +4. A request from test A’s client (or a callback still holding A’s transport) is sent **after** step 2. It goes to P and is handled by **server B**. +5. Server B does `transports.get(sessionId)` → that session ID is from A, not in B’s map → B returns 404 JSON. +6. If the process that took over P is not our test server (e.g. dev server, other app), we can get an **HTML** 404. + +So the flake is either: + +- Our test server on a reused port returning ā€œSession not foundā€ (JSON), or +- Another app on that port returning 404 (often HTML). + +**Why teardown order isn’t enough:** +We already do disconnect-then-stop. The problem is a request that is **sent after** disconnect (e.g. reconnection, retry, or a callback that runs later). Once the server is stopped and the port is reused, that request hits the new listener. + +### Concrete fixes (Streamable HTTP) + +### Fix 1: Distinct 404 body from our test server + +**File:** `test-servers/src/test-server-http.ts` +**Change:** When returning 404 for ā€œSession not foundā€, use a body that is clearly from this test server, e.g. +`res.status(404).json({ error: "Session not found", _mcpTestServer: true })` +or a fixed string like `"SESSION_NOT_FOUND_MCP_TEST"`. +**Reason:** In the thrown error message we see `response.text()`. If we see that token, the 404 is from our server (session lifecycle). If we see HTML or no token, the 404 is from port reuse / another app. That lets us distinguish the two failure modes and fix the right one. + +### Fix 2: Short delay after `server.stop()` in afterEach (streamable HTTP tests) + +**Files:** All test files that use the shared afterEach with `server.stop()` (e.g. inspectorClient.test.ts, pagedResourceTemplatesState.test.ts). +**Change:** After `await server.stop()`, `await new Promise(r => setTimeout(r, 50))` (or 20–50 ms) before the afterEach resolves. +**Reason:** Gives the OS time to release the port and reduces the chance that a very late request (e.g. from a just-fired callback or retry) still reaches the same port after the next test’s server has bound it. Doesn’t eliminate the race but makes it rarer. + +### Fix 3: Optional – run streamable HTTP tests with `--poolOptions.threads=1` or `--sequence` + +**File:** `core/vitest.config.ts` (or root vitest config) +**Change:** For the core project (or a dedicated ā€œhttpā€ project), set `pool: 'forks'` and `poolOptions: { threads: 1 }`, or use `sequence: { concurrent: false }` for the suite that contains these tests. +**Reason:** Ensures one streamable HTTP test runs at a time in that worker, so port assignment and teardown order are deterministic and there’s no cross-test request to a reused port within the same process. Use if Fixes 1–2 are not enough. + +### Fix 4: Assert 404 source when we catch the error (optional) + +**File:** Tests that use streamable HTTP (or a shared test helper). +**Change:** When catching `StreamableHTTPError` with status 404, check the message (or parse the body). If it contains the token from Fix 1, fail with ā€œSession not found from our server – session lifecycle bugā€. If it contains HTML or not our token, fail with ā€œ404 from wrong server – port reuse or wrong URLā€. +**Reason:** Makes the next occurrence of the flake directly tell us which of the two causes it is. + +### Summary (Streamable HTTP) + +| Fix | Where | What | +| --- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | test-server-http.ts POST /mcp 404 | Return a recognizable body (e.g. `_mcpTestServer: true` or fixed string) so 404s from our server can be told apart from others. | +| 2 | afterEach in streamable HTTP tests | After `await server.stop()`, add `await new Promise(r => setTimeout(r, 50))`. | +| 3 | vitest config (optional) | Run streamable HTTP tests with single-thread or sequence so port reuse across tests in the same process is avoided. | +| 4 | Tests or helper (optional) | When catching 404, assert or log whether the body is from our server (Fix 1) to classify the failure. | + +### Recommended order (Streamable HTTP) + +1. **First:** Fix 1 (distinct 404 body) – diagnostic; next flake tells us port-reuse vs session lifecycle (if the body appears in the failure output). +2. **If flake continues:** Fix 2 (delay after stop) to reduce port-reuse probability. +3. **If still flaky:** Fix 3 (single-thread/sequence for streamable HTTP tests). +4. **Optional:** Fix 4 (assert on 404 body in tests) for clearer failure messages. + +--- + +## 2. Storage adapter (FileStorageAdapter ā€œcreates store and persists stateā€) + +### What the code does + +**Test:** `core/__tests__/storage-adapters.test.ts` – creates a store with `createFileStorageAdapter`, sets state (triggers Zustand persist), then waits for the file to exist and asserts content. + +**Flow:** `setServerState` → Zustand persist middleware → adapter `setItem` → `writeStoreFile` (core/storage/store-io.ts) → `atomically.writeFile`. Atomically writes to a **temp file** in the same directory (e.g. `test-store.json.tmp-*`), then chmod, then rename to final path. All async. + +**Cleanup:** Same describe uses a shared `tempDir`; `afterEach` does `rmSync(tempDir, { recursive: true })` and sets `tempDir = null`. No per-test unique dir. + +### Observed failures + +- **existsSync(filePath) false** at the assertion (inside `vi.waitFor`) – waitFor times out because the file never appears within the current timeout. +- **Unhandled rejection: ENOENT** on chmod of `test-store.json.tmp-*` – `atomically` is still writing/chmodding the temp file when `afterEach` runs and deletes the whole `tempDir`, so a later step (chmod or rename) sees the file gone. + +### Root causes + +1. **Persist slower than waitFor window:** Under load (CI, parallel workers), the first persist can take longer than the current `vi.waitFor` timeout (2000 ms). The test then fails with ā€œexpected trueā€ / existsSync false. +2. **afterEach runs before persist completes:** The test may finish (e.g. after waitFor passes and assertions run), but a **subsequent** persist (e.g. extra debounced write or another state update) can still be in flight. afterEach runs immediately and `rmSync(tempDir)` removes the directory while atomically is still using a temp file inside it → ENOENT on chmod/rename. + +### Concrete fixes (Storage adapter) + +**Fix S1: Increase waitFor timeout and interval** + +**File:** `core/__tests__/storage-adapters.test.ts` +**Change:** In both tests that use `vi.waitFor(() => expect(existsSync(filePath)).toBe(true), ...)`, increase `timeout` from 2000 to 5000 and optionally `interval` from 20 to 50. +**Reason:** Gives persist time to complete under CI/load so the ā€œfile never appearsā€ flake is reduced. + +**Fix S2: Delay before rmSync in afterEach** + +**File:** `core/__tests__/storage-adapters.test.ts` +**Change:** In afterEach, before `rmSync(tempDir, ...)`, add `await new Promise(r => setTimeout(r, 100))` (and make the afterEach callback `async` if needed, or use a sync delay if the hook doesn’t support async). +**Reason:** Allows in-flight persist writes (temp file + chmod + rename) to finish before the directory is deleted, reducing ENOENT on the temp file. + +**Fix S3: Unique tempDir per test** + +**File:** `core/__tests__/storage-adapters.test.ts` +**Change:** Create `tempDir` in each test (e.g. `mkdtempSync(join(tmpdir(), "inspector-storage-test-"))`) and set it on a variable that afterEach cleans. Already done in each test; ensure afterEach only cleans the current test’s dir and that no test reuses another test’s tempDir. (Currently tempDir is set inside the test and cleared in afterEach, so this is already per-test; the main gain is ensuring we don’t share a dir across tests. If tests run in parallel, use a unique suffix like `Date.now()` or a random id so parallel runs don’t collide.) +**Reason:** Avoids one test’s cleanup deleting another test’s directory; complements S2 for same-test in-flight write. + +**Fix S4 (optional): Flush / close before cleanup** + +**File:** Adapter or store API (if we add it), then `core/__tests__/storage-adapters.test.ts` +**Change:** If we expose a ā€œflushā€ or ā€œcloseā€ that waits for pending writes (e.g. from Zustand persist), call it in afterEach before rmSync. +**Reason:** Definitive way to avoid ENOENT; requires API surface and possibly changes in how we use persist. + +### Recommended order (Storage adapter) + +1. **First:** Fix S1 (increase waitFor timeout/interval) – low risk, addresses ā€œfile never appearsā€ under load. +2. **Second:** Fix S2 (short delay before rmSync in afterEach) – simple, reduces ENOENT on temp file. +3. **Optional:** Fix S3 if parallel runs share dirs; Fix S4 if we want a robust API for tests. + +--- + +## 3. OAuth E2E (waitForOAuthWellKnown timeout) + +### What the code does + +**Test:** `core/__tests__/inspectorClient-oauth-e2e.test.ts` – many tests do `server.start()`, then `waitForOAuthWellKnown(serverUrl)` (sometimes twice) before creating the client and running the flow. + +**Helper:** `test-servers/src/test-helpers.ts` – `waitForOAuthWellKnown(serverBaseUrl, options)` polls GET `/.well-known/oauth-authorization-server` with default `timeout = 5000`, `interval = 50`, `requestTimeout = 1000`. Each attempt aborts after 1s; throws if no `res.ok` within 5s total. + +**Server:** `TestServerHttp` with OAuth enabled calls `setupOAuthRoutes(app, this.config.oauth)` **before** `listen()`. The well-known route is registered at app creation; it’s served as soon as the HTTP server is accepting connections. Listen callback runs when the server is bound; there is no "listening but routes not ready" window. + +**Observed flake rate:** This test has passed thousands of times and failed once. Treat as a very rare flake. + +### Observed failure + +- **waitForOAuthWellKnown timed out after 5000ms: http://localhost:<port>/.well-known/oauth-authorization-server** – every attempt for 5s either threw (connection error, abort) or got non-200. + +### We already ensure "server is ready" + +We don't assume "listen = ready". `waitForOAuthWellKnown` **is** the readiness check: we poll until we get 200. Same pattern elsewhere: **waitForRemoteStore** (poll GET /api/storage until predicate), **waitForStateFile** (poll file until predicate). So OAuth E2E is already doing the right thing. + +### Can a request hang after listen? + +No, in this setup: + +- Routes are attached before `listen()`. When the listen callback runs, the server is bound and Express will dispatch the next request. A request sent right after `server.start()` either connects and gets a response (possibly slow) or fails to connect (e.g. ECONNREFUSED). +- Each poll attempt is bounded by `requestTimeout` (1s) via AbortController. So no single request can hang for 5s; we retry. The failure is "we never got `res.ok` in 5s across multiple attempts" (connection errors, non-200, or aborts). + +So the one-off failure is more consistent with a rare event (wrong URL/port, CI blip, resource contention) than with "server slow to become ready." Increasing the total timeout (5s to 10s) is unlikely to help and would only delay the failure when the server is actually down. + +### Concrete fixes (OAuth E2E) + +**Fix O1: Better diagnostic when it times out (recommended if it happens again)** **(done)** + +**File:** `test-servers/src/test-helpers.ts` +**Change:** In `waitForOAuthWellKnown`, track the last response status and last error. When throwing the final timeout error, include them in the message (e.g. `lastStatus: ${lastRes?.status}, lastError: ${lastErr?.message}`). +**Reason:** Next time it fails we can see whether it was connection refused, 404, 500, or repeated aborts – and fix the right cause instead of guessing. + +**Fix O2 (optional): Short delay after server.start() before first poll** + +**File:** `core/__tests__/inspectorClient-oauth-e2e.test.ts` +**Change:** After building `serverUrl`, add `await new Promise(r => setTimeout(r, 200));` before `waitForOAuthWellKnown(serverUrl)`. +**Reason:** Theoretically lets the event loop finish the listen callback before the first fetch; low cost, may reduce an edge case. + +**Fix O3 (optional): Use 127.0.0.1 in serverUrl** + +**File:** `core/__tests__/inspectorClient-oauth-e2e.test.ts` +**Change:** Use `const serverUrl = \`http://127.0.0.1:${port}\`;` instead of `localhost`. +**Reason:** Matches the server’s bind address; avoids any localhost (e.g. IPv6) resolution edge cases. + +**Not recommended:** Increasing the 5s total timeout. 5s is already long; a once-in-thousands flake is unlikely to be fixed by 10s, and it only slows the failure when the server is actually down. + +### Recommended order (OAuth E2E) + +1. **Fix O1 applied:** Timeout error now includes lastStatus and lastError. If it fails again, the message will show what happened. +2. **Optional:** Fix O2 (short delay), Fix O3 (127.0.0.1). Skip increasing the timeout. + +--- + +## 4. Web App – Sampling Reject (inspector-web) + +### What the code does + +**Test:** `web/src/__tests__/App.samplingNavigation.test.tsx` – ā€œshows sampling request and Reject calls rejectā€. The test renders the App, clicks Connect, waits for the Sampling tab, dispatches a fake `newPendingSample` event with a `reject` callback (vi.fn()), then finds the Reject button, clicks it, and expects the reject callback to have been called. + +**Flow:** The App’s listener adds the request (with the test’s `rejectFn`) to state; the Sampling tab renders `SamplingRequest` with an Approve/Reject button; Reject calls `onReject(request.id)`, which triggers `handleRejectSampling(id)` in App, which finds the request and calls `request.reject(error)`. + +### Observed failure + +- **AssertionError: expected "vi.fn()" to be called at least once** – `rejectFn` is never invoked before the `waitFor` times out (line 478: `expect(rejectFn).toHaveBeenCalled()`). + +### Root causes + +1. **Timing:** The test relies on the comment ā€œHandler already set activeTab to samplingā€ and immediately does `findByRole("button", { name: /Reject/i })` then click. It does **not** wait for the sampling request UI to be in the document (unlike the sibling ā€œApproveā€ test, which waits for `screen.getByTestId("sampling-request")` before clicking Approve). Under load or slow renders, the Reject button may not yet be wired to this sample’s `reject` callback, or state may not have updated yet, so the click either hits nothing or a different control. +2. **Wrong button:** If another ā€œRejectā€ exists elsewhere (e.g. another tab or dialog), `findByRole("button", { name: /Reject/i })` could resolve to that button, so the click would not trigger the sampling request’s reject. +3. **waitFor timeout:** The final `waitFor(() => expect(rejectFn).toHaveBeenCalled())` uses the default timeout; if the callback is invoked asynchronously after a slow update, the assertion can time out. + +### Concrete fixes (Web Sampling Reject) + +**Fix W1: Wait for sampling request before clicking Reject** **(done)** + +**File:** `web/src/__tests__/App.samplingNavigation.test.tsx` +**Change:** After dispatching the sample (the `act` that calls `dispatchNewPendingSample!`), wait for the sampling request container to be in the document before finding and clicking Reject, matching the Approve test. For example: +`await waitFor(() => { expect(screen.getByTestId("sampling-request")).toBeInTheDocument(); }, { timeout: 3000 });` +Then find the Reject button, optionally scoped to that container: +`const rejectButton = within(screen.getByTestId("sampling-request")).getByRole("button", { name: /Reject/i });` +**Reason:** Ensures the sampling UI and the request (with our `rejectFn`) are rendered and wired before we click; avoids clicking before state has updated or hitting the wrong Reject. + +**Fix W2: Scope Reject button to the sampling request container** **(done)** + +**File:** `web/src/__tests__/App.samplingNavigation.test.tsx` +**Change:** Use `within(screen.getByTestId("sampling-request")).getByRole("button", { name: /Reject/i })` (after waiting for `sampling-request` per W1) instead of `screen.findByRole("button", { name: /Reject/i })`. +**Reason:** Guarantees we click the Reject for this sample, not any other Reject on the page. + +**Fix W3 (optional): Longer timeout for rejectFn assertion** + +**File:** `web/src/__tests__/App.samplingNavigation.test.tsx` +**Change:** In the final `waitFor(() => expect(rejectFn).toHaveBeenCalled())`, pass `{ timeout: 3000 }` (or 5000) so a slow async path still passes. +**Reason:** Reduces flake when the handler runs slightly after the default waitFor window. + +### Recommended order (Web Sampling Reject) + +1. **First:** Fix W1 (wait for `sampling-request` before clicking Reject) – aligns with the Approve test and removes the main timing race. **(done)** +2. **With W1:** Fix W2 (scope button to `sampling-request`) – ensures we click the correct Reject. **(done)** +3. **Optional:** Fix W3 (longer timeout on the rejectFn assertion) if the flake persists. + +--- + +## Summary table (all flaky areas) + +| Area | Fix | File / location | What | +| --------------- | --- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| Streamable HTTP | 1 | test-server-http.ts POST /mcp 404 | Return recognizable 404 body (e.g. `_mcpTestServer: true`). | +| Streamable HTTP | 2 | afterEach in streamable HTTP tests | After `await server.stop()`, add ~50 ms delay. | +| Streamable HTTP | 3 | vitest config (optional) | Single-thread or sequence for streamable HTTP tests. | +| Streamable HTTP | 4 | Tests (optional) | On 404, assert body to classify our server vs other. | +| Storage adapter | S1 | storage-adapters.test.ts | waitFor timeout 2000 → 5000, interval 20 → 50. | +| Storage adapter | S2 | storage-adapters.test.ts afterEach | Short delay (e.g. 100 ms) before rmSync(tempDir). | +| Storage adapter | S3 | storage-adapters.test.ts (optional) | Ensure unique tempDir per test (e.g. timestamp/random). | +| OAuth E2E | O1 | test-helpers.ts | On timeout, include last response status and last error in thrown message (diagnostic). (done) | +| OAuth E2E | O2 | inspectorClient-oauth-e2e.test.ts (optional) | Short delay after server.start() before waitForOAuthWellKnown. | +| OAuth E2E | O3 | inspectorClient-oauth-e2e.test.ts (optional) | Use http://127.0.0.1:${port} for serverUrl. | +| Web Sampling | W1 | web/src/**tests**/App.samplingNavigation.test.tsx | Wait for `sampling-request` in doc before finding/clicking Reject. (done) | +| Web Sampling | W2 | web/src/**tests**/App.samplingNavigation.test.tsx | Scope Reject button with within(getByTestId("sampling-request")). (done) | +| Web Sampling | W3 | web/src/**tests**/App.samplingNavigation.test.tsx (optional) | Longer timeout on waitFor(rejectFn).toHaveBeenCalled(). | + +--- + +## Recommended order (all flakes) + +1. **Streamable HTTP:** Apply Fix 1 (distinct 404 body for diagnostics). If flake continues, Fix 2 (delay after stop), then Fix 3 (single-thread/sequence). Optional: Fix 4 (assert on 404 body). +2. **Storage adapter:** Apply Fix S1 (waitFor timeout/interval), then Fix S2 (delay before rmSync). Add S3/S4 if needed. +3. **OAuth E2E:** Fix O1 (diagnostic) applied. Optionally O2 (delay), O3 (127.0.0.1). Do not increase timeout. +4. **Web Sampling Reject:** Fix W1 and W2 applied. Add W3 if still flaky. diff --git a/docs/form-generation-extraction-plan.md b/docs/form-generation-extraction-plan.md new file mode 100644 index 000000000..9a2d14b51 --- /dev/null +++ b/docs/form-generation-extraction-plan.md @@ -0,0 +1,263 @@ +# Form Generation Extraction Plan + +## Goal + +Provide a **reusable form layer** (schema → defaults, form UI, validation, cleaned payload) that **any UX for interacting with MCP servers** can use—with or without InspectorClient. + +**Examples:** + +- **Inspector web app:** Tool input forms, elicitation forms, (later) server-config forms. Uses InspectorClient; form layer handles schema-driven UI and param cleaning. +- **Another React app using InspectorClient:** Same tool input and elicitation flows without reimplementing form-building and processing. +- **Agent or app with no InspectorClient:** An agent that uses MCP and receives a **form-based elicitation request** (e.g. `elicitation/create` with `requestedSchema`) must present a form to the user and turn their input into a valid elicitation response. It needs: generate a form from the schema, collect input, validate, and produce the response payload. No InspectorClient involved—just schema in, valid response out. +- **Server-config UX:** Forms to collect MCP server config from a server.json or ServerCard spec (registry, web-based cards). Same schema→form→clean pipeline. + +The extraction should deliver a **general-purpose form capability** for MCP UX: schema-driven form generation and processing that works for tool parameters, elicitation, server config, or any other JSON-Schema–based MCP flow. + +--- + +## What ā€œForm Generationā€ Must Include for the Consumer + +1. **Schema → default values** + Given a JSON Schema (e.g. tool `inputSchema`, elicitation `requestedSchema`, or server spec), produce the initial value object. Requires handling `$ref`, `required`, nested objects, and common union/nullable patterns. + +2. **Schema → editable form UI** + A React component that, given `schema`, `value`, `onChange`, renders the form: string, number, integer, boolean, enum, nullable tri-state, object (nested), array (with add/remove), and JSON fallback when structure is too complex. Same behavior whether the schema came from a tool, an elicitation request, or a server spec. + +3. **Schema-aware cleaning** + Before sending to MCP (e.g. `tools/call` or elicitation response), clean the form value: strip optional empty/null/undefined per schema so the payload is valid and minimal. + +4. **Validation** + Optional but valuable: required fields, formats (e.g. email), and optionally full JSON Schema validation so the app can reject invalid input before submit. + +5. **Types** + Shared TypeScript types for schema and values (`JsonSchemaType`, `JsonValue`, etc.). + +6. **$ref resolution and union normalization** + The layer resolves $refs and normalizes union types so that schemas that reference definitions or use anyOf/union types ā€œjust workā€ in the form and in defaults/cleaning. Ref support includes resolving refs into `$defs`(draft 2019-09+) and`definitions` (draft-07). + +**Deliverable:** Defaults + form UI component(s) + cleaning + types + optional validation, usable for tool input, elicitation response, server config, or any other MCP schema-driven flow—with or without InspectorClient. + +--- + +## Current Inspector web app: where form-from-schema is used + +### 1.1 Tool input (Tools tab) + +- **Files:** `web/src/components/ToolsTab.tsx`, `web/src/App.tsx` +- **Flow:** + - ToolsTab reads `selectedTool.inputSchema` (MCP tool `inputSchema`). + - Builds initial params with `resolveRef` + `generateDefaultValue` per property (ToolsTab ~120–137). + - Renders one block per property: resolve ref → `normalizeUnionType` → `isPropertyRequired` → then either inline controls (checkbox for boolean, Select for enum, etc.) or `` for complex/object props (ToolsTab ~377, ~442). + - `App.callTool` uses `cleanParams(params, tool.inputSchema)` before sending. +- **Schema helpers used:** `resolveRef`, `generateDefaultValue`, `isPropertyRequired`, `normalizeUnionType` from `schemaUtils`; `JsonSchemaType` from `jsonUtils`; `cleanParams` from `paramUtils`. + +### 1.2 Tool input (Apps tab) + +- **Files:** `web/src/components/AppsTab.tsx` +- **Flow:** Same pattern as ToolsTab: `inputSchema.properties` → `resolveRef` + `generateDefaultValue` for initial params; per-property resolve + `normalizeUnionType` + `isPropertyRequired`; mix of inline controls and ``. cleanParams used when the app runs the tool. +- **Same helpers:** schemaUtils + jsonUtils + paramUtils. + +### 1.3 Form elicitation + +- **Files:** `web/src/components/ElicitationRequest.tsx` +- **Flow:** Receives `request.request.requestedSchema` (JSON Schema). Uses `generateDefaultValue(request.request.requestedSchema)` for initial form data; renders a single ``; validates with Ajv against that schema on Submit. +- **Helpers:** `generateDefaultValue` from schemaUtils; validation is local (Ajv) in the component. + +### 1.4 Sampling request + +- **Files:** `web/src/components/SamplingRequest.tsx` +- **Flow:** Builds a fixed `JsonSchemaType` (model, stopReason, role, content) in code and uses ``. Not driven by an external schema from the server; same form component. + +### 1.5 Parameter cleaning (pre-send) + +- **Files:** `web/src/utils/paramUtils.ts`, `web/src/App.tsx` +- **Flow:** `cleanParams(params, tool.inputSchema)` strips optional empty values before `tools/call`. Depends on `JsonSchemaType` and `required`/`properties`. + +--- + +## Conversion: how the web app will use the extracted functionality + +After extraction, the web app will stop using local schema/form implementations and instead use the core form layer. Concrete changes: + +| Current usage | Conversion | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1.1 Tools tab** | Import `generateDefaultValue`, `resolveRef`, `isPropertyRequired`, `normalizeUnionType`, `cleanParams`, and types from core (e.g. `@modelcontextprotocol/inspector-core/schema` or `/react`). Replace `DynamicJsonForm` with core’s `SchemaForm` (or keep existing layout but have inline controls and the complex-property form component use core’s component and helpers). `App.callTool` continues to call `cleanParams(params, tool.inputSchema)` from core before sending. Remove or thin out `web/src/utils/schemaUtils` and `jsonUtils` usage for these paths. | +| **1.2 Apps tab** | Same as Tools tab: import all schema helpers and types from core; use core’s `SchemaForm` where `DynamicJsonForm` is used today; use core’s `cleanParams` when running the tool. Remove duplicate schema/form logic from AppsTab. | +| **1.3 Form elicitation** | Import `generateDefaultValue` and types from core; use core’s `SchemaForm` (or keep a single `` usage as today). Validation can stay in web with Ajv, or use a core-provided validator if we add one. Remove dependency on web’s schemaUtils for defaults. | +| **1.4 Sampling request** | Import types and use core’s `SchemaForm`; the fixed schema is still built in the component (or in a shared constant), but the form UI and default-value logic come from core. | +| **1.5 Parameter cleaning** | Remove `web/src/utils/paramUtils.ts` (or reduce to a re-export of core’s `cleanParams`). `App.tsx` imports `cleanParams` from core. | + +**Web-specific code that can be removed or reduced after conversion:** + +- **`web/src/utils/schemaUtils.ts`:** Move to core: `generateDefaultValue`, `resolveRef`, `isPropertyRequired`, `normalizeUnionType`, `formatFieldLabel`, `cleanParams` (and optionally `resolveRefsInMessage`). Keep in web only what remains (e.g. tool-output validation with Ajv, if not moved). +- **`web/src/utils/jsonUtils.ts`:** Schema-related types (`JsonSchemaType`, `JsonValue`, `JsonObject`) move to core; web imports from core. Keep in web only truly web-specific helpers (e.g. `updateValueAtPath` if used only for React state). +- **`web/src/utils/paramUtils.ts`:** Remove; `cleanParams` comes from core. +- **`web/src/components/DynamicJsonForm.tsx`:** Either remove and use core’s `SchemaForm` everywhere (ToolsTab, AppsTab, ElicitationRequest, SamplingRequest), or keep as a thin wrapper around core’s component that adds web’s design system (shadcn) until we standardize on core’s minimal UI or an adapter. + +**Result:** Web no longer implements form-from-schema or param cleaning; it consumes the same API that any other React app would use. + +--- + +## Proposed Shape of the Reusable Form Layer + +### Schema layer (no React) + +- **Location:** Core, e.g. `core/schema/` or `core/json/`. +- **Contents:** Types (`JsonValue`, `JsonSchemaType`, `JsonObject`); `generateDefaultValue`, `resolveRef`, `isPropertyRequired`, `normalizeUnionType`, `formatFieldLabel`, `cleanParams`. Optionally `resolveRefsInMessage` for elicitation message shapes. +- **Consumer:** Any environment (React, TUI, CLI, agent runtime). E.g. an agent that only needs to produce a valid elicitation response can use `generateDefaultValue(requestedSchema)` and a validator (or the React form) plus `cleanParams`; no InspectorClient. + +### React form layer + +- **Location:** Core’s React surface, e.g. `core/react/forms` (core already has React peer and `./react/*` exports). +- **Contents:** A `SchemaForm` component (schema, value, onChange, optional maxDepth) that renders the full form using the schema helpers; optional hook e.g. `useSchemaFormState(schema)` → `{ value, setValue, defaultValue, cleanForSubmit }`. +- **UI:** Ship with minimal default UI (plain HTML or unstyled components) so any React app can use it without a specific design system; apps can wrap or theme. +- **Consumer:** Any React app that needs to show a form for tool input, elicitation, or server config—whether it uses InspectorClient or not. + +### Recommendation + +- **Schema types + pure helpers** → core (no React). Used by the React form, by non-React UIs (TUI, CLI), and by agents or services that only need defaults/cleaning/validation (e.g. elicitation response generation with no UI). +- **Form UI (React)** → core’s React export. One dependency on inspector-core gives both InspectorClient (if used) and the form layer; an app that only needs forms for elicitation can use the same package and ignore InspectorClient. + +--- + +## What Gets Extracted (Concrete) + +### 1. Core schema module (no React) + +- **Module:** e.g. `core/schema/` or `core/json/`. +- **Types:** `JsonValue`, `JsonSchemaType`, `JsonObject` (and any needed for oneOf/anyOf/const). +- **Functions:** `generateDefaultValue`, `resolveRef`, `isPropertyRequired`, `normalizeUnionType`, `formatFieldLabel`, `cleanParams`; optionally `resolveRefsInMessage`. +- **Tests:** Unit tests for all of the above. + +### 2. Core React form layer + +- **Component:** `SchemaForm` (or `DynamicJsonForm`) with props `schema`, `value`, `onChange`, optional `maxDepth`; uses core schema helpers; minimal default UI. +- **Optional hook:** `useSchemaFormState(schema)` → `{ value, setValue, defaultValue, cleanForSubmit }`. +- **Exports:** SchemaForm, hook, and re-exports of schema helpers and types from the React entry so a consumer can get everything from one import path. + +### 3. Web app after extraction + +- Use core for all schema types and helpers; use core’s SchemaForm (or keep web’s component backed by core helpers initially, then migrate). Web no longer owns the form implementation. See **ā€œConversion: how the web app will use the extracted functionalityā€** above for the detailed mapping per tab/flow. + +### 4. Consumers (with or without InspectorClient) + +- **React app with InspectorClient:** Tool input and elicitation use `SchemaForm` + `generateDefaultValue` + `cleanParams`; no reimplementation. +- **React app without InspectorClient (e.g. agent UX):** On form-based elicitation request, use `generateDefaultValue(request.requestedSchema)` for initial state, ``, validate, then send elicitation response with `cleanParams(formData, requestedSchema)` (or the raw formData if already valid). No InspectorClient integration required. +- **TUI / CLI / non-React:** Use core’s schema helpers and, if we add it, a form descriptor (schema → list of field descriptors) for rendering in a terminal or other non-React UI. + +--- + +## Use Cases + +- **Tool input (tools/call):** Schema = tool `inputSchema`; form collects arguments; clean before `callTool`. +- **Form-based elicitation:** Schema = `requestedSchema` from `elicitation/create`; form collects user input; validate and clean; return as elicitation response. Works with or without InspectorClient. +- **Server-config (future):** Schema = server.json or ServerCard spec; form collects connection config; clean and add to config. +- **Sampling / other MCP flows:** Any JSON-Schema–driven payload can use the same layer. + +--- + +## TUI and other non-React form UIs + +The TUI (and any other app that builds its own form UX instead of using the React component) cannot use the React `SchemaForm` component—it has its own rendering (e.g. Ink, readline, inquirer). It can still benefit from the extracted layer in the following ways. + +### What the TUI (or non-React app) can use + +1. **Schema layer (no React)** + All of the pure helpers and types in core: + - **Defaults:** For tool input or elicitation, call `generateDefaultValue(schema)` instead of reimplementing or hardcoding. Same initial values as the web app. + - **Field shape:** For each property, use `resolveRef`, `normalizeUnionType`, `isPropertyRequired`, and `formatFieldLabel` so the TUI knows type, required, label, and options (e.g. enum). The TUI does not implement `$ref` or union handling; it reuses core. + - **Cleaning:** Before sending a tool call or elicitation response, call `cleanParams(collectedValue, schema)`. Same rules as the web app; no duplicate logic. + - **Validation:** If core exposes a validator (e.g. `validate(schema, value)`), the TUI can validate before submit without bundling its own schema-validation logic. + +2. **Optional: form descriptor** + The plan already mentions a form descriptor for TUI/CLI. Core could expose e.g. `getFormDescriptor(schema)` that returns a list of field descriptors (key, type, required, default, enum, label, nested descriptor, etc.). The TUI would: + - Call `getFormDescriptor(schema)` once. + - Render its own UI from that list (e.g. one Ink or readline prompt per field). + No schema walking or resolution lives in the TUI; one canonical, well-tested interpretation of the schema. The React form could use the same descriptor internally so web and TUI stay in sync. + +3. **Consistency** + Same defaults, same cleaning, and same validation rules as the web app. Behavior for a given schema is consistent across UIs; only the rendering (React vs terminal) differs. + +### Other apps with their own form UX + +The same applies to any non-React app that needs to generate its own form UX: Vue, mobile, another CLI, etc. They do not use the React component; they use the schema layer (and optionally the descriptor) to drive their own rendering, and they use `cleanParams` and validation from core so they do not reimplement that logic. Schema interpretation (what fields exist, types, defaults, required, options) lives in one place; rendering is up to each app. + +--- + +## Testing + +Extracting the form layer into core makes it **testable in isolation**. Today, form logic is embedded in the web app, so testing complex schemas and edge cases means running through full UI flows or mocking large parts of the app. After extraction, we can test the schema helpers and the form component directly, and optionally add a **test harness** plus e2e (e.g. Playwright) for the full pipeline when that adds value. + +### Unit tests + +- **Core schema helpers (no React):** Once `generateDefaultValue`, `resolveRef`, `isPropertyRequired`, `normalizeUnionType`, `formatFieldLabel`, and `cleanParams` live in core, add unit tests in core for: + - Simple types (string, number, integer, boolean, array, object, null) and required vs optional. + - Nested objects and arrays; `required` and `default`; `$ref` resolution (including failure cases). + - Union/nullable patterns (`anyOf`, `type: ["string","null"]`, etc.) that `normalizeUnionType` handles. + - `cleanParams`: optional empty values stripped, required and defaults preserved. + - Edge cases: empty schema, missing properties, invalid refs, deep nesting. + These are straightforward to cover thoroughly because the code is pure and has no DOM or React. + +- **React form component:** If core ships a `SchemaForm` (or equivalent), add unit tests in core with React Testing Library: + - Renders correct controls for each schema type (string, number, boolean, enum, object, array, etc.). + - Initial value matches `generateDefaultValue(schema)`. + - User changes propagate via `onChange`; optional hook `useSchemaFormState` behaves correctly. + - JSON fallback when schema is too complex; maxDepth behavior. + - Validation (if built in) and accessibility basics. + This gives good coverage of the component in isolation but may not exercise every browser quirk or full ā€œfill form → submit → assert outputā€ flows. + +Unit tests alone may **not** be sufficient for: + +- Full pipeline: schema → rendered form → user input → validation → cleaned output, in a real browser. +- Complex or brittle schemas (e.g. from real tools or elicitation) that only fail when rendered and filled. +- Interaction edge cases (nested objects, dynamic arrays, conditional UI) that are easier to catch by driving the real form. + +### Test harness app and e2e (Playwright) + +If we need to validate the **full pipeline** and hard-to-reach edge cases, add a **test harness app** that: + +1. **Renders** a form from a given JSON Schema (using core’s SchemaForm and helpers). +2. **Collects** user input (or programmatic input from tests). +3. **Validates** the collected value against the schema (using core or a shared validator). +4. **Produces** the final output (e.g. cleaned via `cleanParams`) and exposes it for assertions. + +The harness is a **minimal app** (no InspectorClient, no full Inspector UI)—just schema in, form UI, submit, output out. It can be a small React app in the repo (e.g. `test/form-harness/` or under `core/` or `web/`) that we run in dev or in CI. + +**E2E with Playwright** (or similar) would then: + +- Load the harness (or a dedicated test page that uses the same form layer). +- For each test scenario: set or select a schema (simple, complex, edge-case), optionally fill the form (or use seeded values), submit, and assert on the output (structure, required fields present, optional empties stripped, validation errors when invalid). +- Cover scenarios that are awkward to test with unit tests alone: e.g. ā€œobject with 10 optional fields, user fills 2, output has only those 2,ā€ ā€œarray of objects with nested refs,ā€ ā€œinvalid enum value triggers validation error.ā€ + +**When a harness + e2e adds value:** + +- We want confidence that the **entire path** (schema → defaults → form → input → validate → clean) works in a real browser for a curated set of schemas (including complex and edge-case). +- We introduce schemas that have historically caused bugs (e.g. from real MCP tools or elicitation) and lock them in as e2e scenarios. +- We’re willing to maintain a small harness and a Playwright (or similar) suite in exchange for catching integration and browser-specific issues. + +**Recommendation:** Treat **unit tests as the baseline**: comprehensive tests for core schema helpers and for the React form component in core. Add a **test harness app + Playwright e2e** if we find that (a) unit tests are not sufficient to catch regressions or edge cases, or (b) we want a single place to run ā€œfull pipelineā€ tests against complex and real-world schemas. The extraction itself makes both options feasible; we can start with unit tests and introduce the harness and e2e in a later phase if they add value. + +--- + +## Phased Rollout + +1. **Phase 1 – Core schema + helpers** + Add types and pure functions to core. Web and other consumers import from core. No React form yet. **Testing:** Unit tests in core for all schema helpers (simple and complex schemas, edge cases). + +2. **Phase 2 – Core React form component** + Add SchemaForm and optional hook in core/react with minimal default UI. Export from core. Any React-based MCP UX (Inspector web, another app, an agent’s React UI) can use it for tool input, elicitation, or server config. **Testing:** Unit tests for the form component (RTL) in core. Optionally add test harness app + Playwright e2e if full-pipeline and browser-level coverage is needed (see **Testing** above). + +3. **Phase 3 – Web fully on core form layer** + Web uses core’s SchemaForm and helpers everywhere; remove duplicate form logic from web. + +4. **Later – Server-config, descriptor API, non-React; harness/e2e if adopted** + Server-config schema support; optional form descriptor for TUI/CLI. If test harness and Playwright e2e were added, maintain and extend them for new schema categories or regression scenarios. + +--- + +## Summary + +- **Goal:** A **reusable form capability** for **any UX that interacts with MCP servers**—with or without InspectorClient. Examples: Inspector web, another React app using InspectorClient, an agent that receives form-based elicitation and must render a form and produce a valid response (no InspectorClient). +- **Deliverable:** Schema types + pure helpers in core; React form component + optional hook in core’s React surface; cleaning and validation. Usable for tool parameters, elicitation response, server config, or any other JSON-Schema–driven MCP flow. +- **Testing:** Unit tests for schema helpers and form component as baseline (including complex schemas and edge cases that are hard to test in the current embedded implementation). Optionally a test harness app (render form → collect input → validate → produce output) plus Playwright e2e for the full pipeline when unit tests are insufficient. +- **Outcome:** Any app or agent that needs to generate a form from an MCP schema and process the result into a valid payload can depend on this layer instead of reimplementing it. diff --git a/docs/inspector-client-sub-managers.md b/docs/inspector-client-sub-managers.md new file mode 100644 index 000000000..010cbcbd7 --- /dev/null +++ b/docs/inspector-client-sub-managers.md @@ -0,0 +1,130 @@ +# InspectorClient: Internal Sub-Managers + +This document describes **internal** sub-managers that `InspectorClient` could delegate to in order to reduce the size and complexity of the main class. List state, log state, and requestor task list are handled by **external** state managers (see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)). In scope here: receiver tasks, OAuth, pending samples/elicitation, and optionally roots. + +--- + +## Why Internal Delegation (Sub-Managers) + +### Single entry point and lifecycle + +Consumers expect one client object: one `connect()` / `disconnect()`, one place for environment (transport, fetch, logger, OAuth). Sub-managers are **internal** implementation details. The public API stays on `InspectorClient`; callers do not see or depend on the managers. + +### Shared state and the SDK client + +The MCP SDK `Client` is the single handle to the protocol. OAuth state, receiver task records, and pending samples/elicitation all depend on that connection. Extracting them into **internal** helpers (not separate public classes) keeps one facade that owns the SDK client and delegates to narrow, testable modules. + +### Testability and change isolation + +Managers can be unit-tested in isolation with mocks. Changes to OAuth or receiver-task handling are confined to one module. The main class becomes mostly wiring and delegation. + +--- + +## Sub-Managers + +The following are implemented inside InspectorClient and are candidates for internal extraction. + +### 1. ReceiverTaskManager + +**Responsibility:** Server-initiated tasks: create task record, TTL cleanup, hold payload promise, upsert/cancel, and notify the server of status. + +**State:** `receiverTaskRecords` map, TTL config. + +**Methods:** `createReceiverTask`, `emitReceiverTaskStatus`, `upsertReceiverTask`, `getReceiverTask`, `listReceiverTasks`, `getReceiverTaskPayload`, `cancelReceiverTask` (and helpers like `isTerminalTaskStatus`). + +**Interface:** Constructor takes `{ ttlMs, onEmitStatus: (task) => void, logger? }`. API: `create(opts)`, `get(taskId)`, `list()`, `getPayload(taskId)`, `upsert(task)`, `cancel(taskId)`. + +**Usage:** Created when the client is set up. Message/elicit handlers call the manager to create records; `tasks/get`, `tasks/cancel`, and `tasks/list` handlers delegate to the manager. Manager calls `onEmitStatus` to send status notifications (InspectorClient implements that with the SDK client). + +--- + +### 2. OAuthManager (OAuthFlowManager) + +**Responsibility:** OAuth config and all OAuth flow orchestration (normal and guided), using existing `OAuthStateMachine` and `BaseOAuthClientProvider`. + +**State:** `oauthConfig`, `oauthStateMachine`, `oauthState`. + +**Methods:** `setOAuthConfig`, `authenticate`, `beginGuidedAuth`, `runGuidedAuth`, `setGuidedAuthorizationCode`, `completeOAuthFlow`, `getOAuthTokens`, `clearOAuthTokens`, `isOAuthAuthorized`, `getOAuthState`, `getOAuthStep`, `proceedOAuthStep`, and private helpers (e.g. `createOAuthProvider`, `getServerUrl` or a callback). + +**Interface:** Constructor takes `getServerUrl`, `fetch`, `logger`, `getEventTarget`, and OAuth env (storage, navigation, redirectUrlProvider). Same public method signatures as today. + +**Usage:** InspectorClient holds the manager when `options.oauth` is present. All public OAuth methods delegate to the manager. + +--- + +### 3. PendingSamplesElicitationManager + +**Responsibility:** Hold pending sampling and elicitation requests and notify when they change. + +**State:** `pendingSamples`, `pendingElicitations`. + +**Methods:** `getPendingSamples`, `addPendingSample`, `removePendingSample`, `getPendingElicitations`, `addPendingElicitation`, `removePendingElicitation`. + +**Interface:** Constructor takes a dispatch callback. API: get/add/remove for each list. + +**Usage:** InspectorClient holds the manager and delegates all six methods. CreateMessage/elicit and elicitation-complete handlers call into the manager. + +--- + +## Optional Extraction + +**Roots:** InspectorClient holds `roots` and exposes `getRoots()`, `setRoots()`, and dispatches `rootsChange`. This could stay as-is or be moved into a small internal **RootsManager** if desired; it is not a state manager in the external sense (no separate consumer-facing list). + +**Content cache:** If present, can remain as an internal dependency or small helper. + +--- + +## What Remains in InspectorClient After Sub-Manager Extraction + +After extracting ReceiverTaskManager, OAuthManager, and PendingSamplesElicitationManager (and optionally roots), InspectorClient would retain: + +**Lifecycle and transport** + +- Constructor: validate options, create sub-managers (injecting adapters/callbacks), set config flags. +- `connect()`: create transport (and optionally wrap with MessageTrackingTransport), attach listeners, register protocol request/notification handlers (which call into ReceiverTaskManager, etc.), run initialize, set up list-changed and other notification handlers. +- `disconnect()`: close transport, clear references, reset or clear managers as needed. + +**SDK client and environment** + +- Owning the MCP SDK `Client` and the transport. +- `getRequestOptions()`, `buildEffectiveAuthFetch()`, and other small helpers used by multiple managers or by `connect()`. + +**Thin public API (delegation)** + +- All existing public methods remain on InspectorClient; many become one-liners delegating to the appropriate manager (OAuth, receiver tasks, pending samples/elicitation) or to the SDK client (list RPCs, ping, callTool, callToolStream, readResource, getPrompt, createMessage, elicit, subscribe/unsubscribe, setLoggingLevel, etc.). +- List and log data come from **external** state managers, not from InspectorClient getters. + +**Protocol and app-renderer wiring** + +- Registration of request/notification handlers in `connect()` that coordinate with ReceiverTaskManager (createMessage/elicit with task, tasks/get, tasks/cancel, tasks/list). +- `getAppRendererClient()` (proxy for the Apps tab). +- Dispatching signal events (\*ListChanged, taskStatusChange, requestorTaskUpdated, message, fetchRequest, stderrLog, saveSession, etc.) so external state managers can subscribe. + +**Session id and roots** + +- `getSessionId()`, `setSessionId()`; the decision of when to dispatch `saveSession` (e.g. before OAuth redirect) remains on InspectorClient. Actual persist/restore is in FetchRequestLogState. +- Roots (and optionally a small RootsManager) unless extracted. + +--- + +## Testing Strategy + +**Dedicated test module per sub-manager.** Add a test file for each extracted manager (e.g. `receiverTaskManager.test.ts`, `oauthManager.test.ts`, `pendingSamplesElicitationManager.test.ts`). Test each manager in isolation with **mocked** dependencies (adapters, dispatch, getRequestOptions). No real transport or full InspectorClient. + +**Move tests from InspectorClient into manager tests when they only validate manager behavior.** Examples: receiver task TTL expiry and cleanup, cancel semantics, OAuth state transitions, pending sample add/remove. After extraction, those scenarios live in the manager tests; InspectorClient tests can drop or reduce equivalent coverage. + +**InspectorClient tests focus on wiring and integration.** Verify (1) the client correctly delegates to each manager, and (2) a small set of end-to-end flows per domain (e.g. connect, server sends createMessage with params.task, client creates a receiver task and responds to tasks/get). Manager tests own detailed scenarios; client tests own lifecycle, delegation, and integration. + +--- + +## Rough Code Impact (Remaining Sub-Managers Only) + +- **Current:** InspectorClient is protocol-only for lists and logs; remaining size is connection, OAuth, receiver tasks, pending samples/elicitation, roots, RPC/stream delegation, and handler registration. +- **Moved out (rough, if all three are extracted):** + - ReceiverTaskManager: ~100–120 lines. + - OAuthManager: ~350–400 lines. + - PendingSamplesElicitationManager: ~40–50 lines. +- **Total moved:** on the order of **500–570 lines** into dedicated modules. +- **Remaining:** Wiring, lifecycle, list RPCs (stateless), notification dispatch, request handlers that delegate to ReceiverTaskManager, roots, sessionId/saveSession, and thin public methods that delegate to managers or the SDK client. + +Line counts are approximate; actual impact depends on boundaries and formatting. diff --git a/docs/inspector-client-todo.md b/docs/inspector-client-todo.md new file mode 100644 index 000000000..f07fcdd03 --- /dev/null +++ b/docs/inspector-client-todo.md @@ -0,0 +1,137 @@ +# Inspector client TODO + +NOTE: This document is maintained by a human developer. Agents should never update this document unless directed specifically to do so. + +## Auth Issues + +### Device Flow + +If we can't bring up a browser endpoint for redirect (like if we're in a container or sandbox) + +- Can we implement "device flow" (RFC 8628) and will IDPs support it generally? +- Device flow feature advertisement has issues (for example, GitHub doesn't show it in metadata, but supports it based on app setting) +- Device flow returns "devide_flow_disabed" error, as well as "access_denied", so maybe we just always try, and on those specific error we try the token mode + +Implement and test device flow / device code to see if it's supported + +- Hosted everything - https://example-server.modelcontextprotocol.io/mcp - not supported +- GitHub - https://api.githubcopilot.com/mcp + - Device flow enabled in Github OAuth app, doesn't show in metadata (which isn't client specific) + - Try it and see if it works (if it works, try it without client_secret to see if that works) +- Others if neither of those work? + +## Auth Issues (note for v1.5/v2) + +Found issues with auth servers (esp minimal/test servers) not supporting the registration of multiple callback URLs + +- Consolidated quick/guided into one endpoint (embedded "mode" in oauth state token, use a single endpoint) + +CORS issues (fixed by remoting auth fetch and transport via API) + +- Found many CORS auth issues from browser + - Must proxy auth fetch to node (see PR #1047 against v1) +- Found issue with CORS stripping mcp-session-id header + - Certain http servers with auth only work via proxy + +## TODO + +### CIMD + +- Publish a static document for inspector client info + - client_id and client_uri must be at same domain +- We have a TUI command line param to set it (--client-metadata-url) +- Add --client-metadata-url param (and CIMD support) to web +- After we deploy our Inspector CIMD file, make it the default for --client-metadata-url (both apps) + +Here are some sample CIMD files (for testing and as examples): + +- MCPJam - https://www.mcpjam.com/.well-known/oauth/client-metadata.json +- VS Code - https://vscode.dev/oauth/client-metadata.json +- mcp-inspect: https://teamsparkai.github.io/mcp-inspect/.well-known/auth/client-metadata.json + +### Auth flow logic + +If we get auth server metadata, then we know definitively whether DCR or CIMD are supported + +- We should not attempt unsupported mechanisms and report an appropriate error if no mechanisms are supported + - For example, GitHub only supports preregisgtered static client - if we don't have client info and CIMD and DCR not supported, stop and error + - This could be "no client_id provided and no other client identification mechanisms supported by server" +- If we don't get auth server metadata, we will fall back to trying default endpoints + - It's highly unlikely that CIMD would be supported by an auth server without metadata + - It's possible that DCR could be supported + +### Container auth callback + +If we are in a container: + +- We can set the callback url via config (--callback-url) for creating the local server (binding / serving) +- We don't have a way to specify a different callback url for the protocol (for example, using a host address and/or mapped port) + +## Auth testing + +- Static client: https://api.githubcopilot.com/mcp (works, requires client_id AND client_secret) +- DCR: https://example-server.modelcontextprotocol.io/mcp (works) +- CIMD: https://stytch-as-demo.val.run/mcp (works - as long as client_id and client_uri use the same domain) + +## v1.5 branch + +### Goal: Parity with v1 client + +- MCP apps work (remaining) + - Fix handler multiplexing in AppRendererClient (AppNotificationHandler multiplexing) +- Update README (client->web, proxy->sandbox) +- Review changes to Client from time of fork to present to make sure we didn't miss anything else + +### Goal: Bring Inspector Web support to current spec + +- Add "sampling with tools" support + - https://github.com/modelcontextprotocol/inspector/issues/932 +- Review v1 project boards for any feature deficiencies to spec + +### Goal: Inspector Web quality + +- Review open v1 bugs (esp auth bugs) to see which ones still apply + +### Next Steps + +- **Unify OAuth storage path and serialization (local vs remote)** + - Migrate the web app to use RemoteOAuthStorage (backed by the Inspector API server’s `/api/storage/:storeId`) instead of BrowserOAuthStorage, so web shares OAuth state with CLI/TUI when using the same server. +- **Extract form generator into core, extend as needed** + - See: [form-generation-extraction-plan.md](form-generation-extraction-plan.md) +- **Redesign launcher** + - Shared config parsing, param parsing is same with any launch method + - No more process spawning (just call the main/run of the desired app) + - See: [launcher-config-consolidation-plan.md](launcher-config-consolidation-plan.md) +- **InspectorClient sub-managers** (architecture and responsibilities) + - See: [inspector-client-sub-managers.md](inspector-client-sub-managers.md) +- Research oauth device flow (esp for CLI/TUI) +- Do a spike with alpha v2 TypeScript SDK when published: https://ts.sdk.modelcontextprotocol.io/v2/ + - Leverage migration skill: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration-SKILL.md + - Document the work/steps to update InspectorCore + - Provide feedback to SDK team + - Document any required changes to InspectorCore (or anything else) + - Possibly need to do this a few times (alpha/beta/release) + +### TUI + +Close feature gap +Follow v2 UX design +Implement test strategy (vitest + Playwright?) +Better forms (test tool, etc) + +- Use shared functionality from core forms support +- Functionality (data types, arrays, arrays of objects, etc) +- UX (cleaner, maybe ditch ink-forms, see if it can be styled better?) + +### Server port in-use behavior / bug + +- In dev mode (in v1 and v1.5) if the attempted (specified or default) port is in use, Vite will find the next available port and use that + - But we will still report that it is listening on the attempted port and will open a browser window at that URL (bug) +- In prod mode, if the attempted (specified or default) port is in use, the server fails to run + +- We should decided what the port-in-use behavior should be and make it consistent between dev and prod - proposed: + - If port is specified via param or env var, only use that port (if in use, fail to start) + - If default port (6274), use first available starting at that value + +- This will require that we run Vite via API instead of spawning it (so we can get the resulting port) +- It will also require progressive port listening logic for prod to mimic that behavior diff --git a/docs/inspector-receiver-task-support.md b/docs/inspector-receiver-task-support.md new file mode 100644 index 000000000..7683ce0d5 --- /dev/null +++ b/docs/inspector-receiver-task-support.md @@ -0,0 +1,162 @@ +# Receiver-side task support (InspectorClient) + +This document describes how receiver-side task support was implemented and tested in InspectorClient: when the server sends `sampling/createMessage` or `elicit` with `params.task`, the client creates a receiver task, returns a task reference immediately, and the server polls `tasks/get` and `tasks/result`; the existing Sampling/Elicitations UX resolves the task when the user responds. + +--- + +## 1. Overview: requestor tasks vs receiver tasks + +InspectorClient supports two kinds of tasks. + +**Requestor tasks** + +- **Direction:** Client → server. We send a request that creates a task on the **server** (e.g. `tools/call` with `task: { ttl }`). The server returns a task reference. +- **Storage:** The **list** of requestor tasks is held by optional state managers (e.g. `PagedRequestorTasksState`, `ManagedRequestorTasksState`) in `core/mcp/state/`; see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). InspectorClient still provides the RPCs (`listRequestorTasks`, `getRequestorTask`, etc.) and dispatches task-related events; in-flight task tracking may remain in InspectorClient. +- **Flow:** We poll the server with `tasks/get` and `tasks/result` until the task completes. +- **APIs:** `getRequestorTask`, `getRequestorTaskResult`, `listRequestorTasks`, `cancelRequestorTask`; state managers expose the list and subscribe to `taskStatusChange`, `requestorTaskUpdated`, etc. + +**Receiver tasks** + +- **Direction:** Server → client. The **server** sends us a request with `params.task` (e.g. `sampling/createMessage` or `elicit`). We create the task **locally**, return a task reference immediately, and the server polls **us**. +- **Storage:** `receiverTaskRecords: Map`. +- **Flow:** We create the task, push the request into the same Sampling/Elicitations UI; when the user responds we resolve the task’s payload and send `notifications/tasks/status`. The server calls `tasks/get` and `tasks/result` on us; we implement handlers that delegate to receiver-task methods. +- **APIs:** `getReceiverTask`, `listReceiverTasks`, `getReceiverTaskPayload`, `cancelReceiverTask`, `createReceiverTask`, `emitReceiverTaskStatus`, `upsertReceiverTask`. + +Same MCP task protocol; opposite roles. Requestor tasks = we poll the server. Receiver tasks = the server polls us. + +--- + +## 2. Use case and behavior + +- **Server** sends `sampling/createMessage` or `elicit` with optional `params.task` (e.g. `{ ttl?: number }`). +- **Without** `params.task`: existing behavior—handler returns a promise that resolves when the user responds in the Sampling or Elicitations tab. +- **With** `params.task`: the client (1) creates a receiver task (taskId, status, ttl, etc.), (2) **returns immediately** with `{ task: record.task }` (CreateTaskResult), (3) still pushes the request into the same pending list and fires `newPendingSample` / `newPendingElicitation` so the existing UI shows it, (4) when the user calls `respond(result)` or `reject(error)`, resolves or rejects the **receiver task’s payload promise** and sets task status to completed or failed, (5) sends `notifications/tasks/status` with the updated task (fire-and-forget; see below), (6) runs TTL cleanup (removes the task record after ttl ms). The server polls `tasks/get` and `tasks/result`; `tasks/result` blocks until the payload promise is resolved or rejected. +- **UX:** Unchanged: user sees the same Sampling or Elicitations tab and the same resolve/reject actions. Only the protocol response shape and server-visible task lifecycle change when `params.task` is present. + +--- + +## 3. Implementation + +### 3.1 Options and capabilities + +**InspectorClientOptions** + +- **`receiverTasks?: boolean`** (default false) + When true, InspectorClient advertises client capabilities for receiver tasks (`tasks.list`, `tasks.cancel`, `tasks.requests.sampling.createMessage`, `tasks.requests.elicitation.create`) and implements the full receiver-task flow. When false, we do not add the tasks capability or any receiver-task handlers. + +- **`receiverTaskTtlMs?: number | (() => number)`** + Used only when `receiverTasks` is true. TTL for receiver tasks when the server does not send a `ttl` in `params.task`. If a function, it is called at task creation time. Default is 60_000. + +**Capabilities (constructor)** +When `receiverTasks` is true, we add to `ClientCapabilities`: +`tasks: { list: {}, cancel: {}, requests: { sampling: { createMessage: {} }, elicitation: { create: {} } } }`. +We do not derive this from `sample`/`elicit`; the creator opts in with `receiverTasks: true`. + +### 3.2 State and types + +- **`receiverTaskRecords: Map`** + Key = taskId. Cleared in `disconnect()` and when the TTL timer fires. + +- **`ReceiverTaskRecord`** (internal): + - `task: Task` + - `payloadPromise: Promise` (resolved with CreateMessageResult or ElicitResult, or rejected with error) + - `resolvePayload: (payload: ClientResult) => void` + - `rejectPayload: (reason?: unknown) => void` + - `cleanupTimeoutId?: ReturnType` (for TTL cleanup) + +### 3.3 Helpers + +- **`createReceiverTask(opts: { ttl?: number; initialStatus; statusMessage?; pollInterval? }): ReceiverTaskRecord`** + Generates taskId (e.g. `crypto.randomUUID()` or fallback), computes `ttl = opts.ttl ?? receiverTaskTtlMs`, creates the Task object, creates a promise with resolve/reject, builds the record, stores it in `receiverTaskRecords`, and schedules TTL cleanup. Returns the record. + +- **`emitReceiverTaskStatus(task: Task): void`** + Sends `this.client.notification({ method: "notifications/tasks/status", params: task })`. Implemented as synchronous void: `client.notification()` is invoked and errors are handled with `.catch()` and logging so a failed notification does not break the flow. + +- **`upsertReceiverTask(task: Task): void`** + Updates the record in `receiverTaskRecords` for `task.taskId` (sets `record.task = task`), then calls `emitReceiverTaskStatus(task)`. Also synchronous void; notification is fire-and-forget. + +- **Terminal status** + A private static `isTerminalTaskStatus(status)` returns true for `completed` / `failed` / `cancelled`. We use this instead of the SDK’s experimental `isTerminal` to avoid depending on experimental API and to get a type predicate for narrowing. + +**Receiver-task accessors (used by protocol handlers and internally)** + +- **`getReceiverTask(taskId): ReceiverTaskRecord | undefined`** — `receiverTaskRecords.get(taskId)`. +- **`listReceiverTasks(): Task[]`** — `Array.from(receiverTaskRecords.values()).map(r => r.task)`. +- **`getReceiverTaskPayload(taskId): Promise`** — Looks up the record; if missing, throws `McpError(InvalidParams, "Unknown taskId: ...")`. Returns `record.payloadPromise` (the server’s `tasks/result` awaits this). +- **`cancelReceiverTask(taskId): Task`** — Looks up record; if missing, throws. If status is already terminal, returns `record.task`. Otherwise sets status to cancelled, calls `record.rejectPayload(...)`, clears cleanup timeout, calls `emitReceiverTaskStatus(updatedTask)`, returns updated task. + +### 3.4 CreateMessage handler (connect) + +When `receiverTasks` is true, the CreateMessage handler: + +- **If no `params.task`:** Current behavior: create `SamplingCreateMessage(...)`, `addPendingSample(...)`, return the promise. +- **If `params.task` is present:** + - Call `createReceiverTask({ ttl: params.task.ttl, initialStatus: "input_required", statusMessage: "Awaiting user input" })`. + - Return **immediately** with `{ task: record.task }`. + - In the background: create the same `SamplingCreateMessage(request, resolve, reject, removeCallback)` and call `addPendingSample(samplingRequest)`. On `resolve`: call `record.resolvePayload(payload)`, set `record.task` status to `"completed"`, call `upsertReceiverTask(updatedTask)`. On `reject`: call `record.rejectPayload(error)`, set status `"failed"`, set statusMessage from error, call `upsertReceiverTask(updatedTask)`. + +### 3.5 Elicit handler (connect) + +Same pattern as CreateMessage when `receiverTasks` is true: no `params.task` → current behavior; with `params.task` → createReceiverTask, return `{ task: record.task }`, in background add to pending elicitations; when the user calls `respond(result)`, call `record.resolvePayload(result)`, set task completed, upsertReceiverTask; on reject/decline, call `record.rejectPayload`, set failed, upsertReceiverTask. + +**ElicitationCreateMessage and decline** +For task-augmented elicit, when the user declines we must reject the receiver task’s payload so the server’s `tasks/result` receives an error. `ElicitationCreateMessage` exposes a public method `reject(error: Error)`; the optional constructor callback is stored internally and invoked by `reject()`. The App’s decline handler calls `elicitation.reject(error)` when present, then `elicitation.remove()` as usual. + +### 3.6 Task request handlers (connect, only when receiverTasks is true) + +Handlers are registered for ListTasks, GetTask, GetTaskPayload, CancelTask. Each delegates to the corresponding receiver-task method. + +- **ListTasks:** Returns `{ tasks: this.listReceiverTasks() }`. +- **GetTask:** Calls `this.getReceiverTask(request.params.taskId)`; if undefined, throws McpError(InvalidParams); returns `record.task`. +- **GetTaskPayload:** Returns `await this.getReceiverTaskPayload(request.params.taskId)` (throws if unknown; server request blocks until payload resolved or rejected). +- **CancelTask:** Returns `this.cancelReceiverTask(request.params.taskId)`. + +Notification payload is built with `TaskStatusNotificationSchema` (from SDK). Response type for `{ task: record.task }` is inferred; `CreateTaskResultSchema` is not imported. + +### 3.7 disconnect() + +Before clearing `trackedRequestorTasks`, iterate `receiverTaskRecords`, clear any `cleanupTimeoutId` (clearTimeout), then `receiverTaskRecords.clear()`. + +### 3.8 Web app integration + +- **InspectorClientOptions:** The app creates InspectorClient with `receiverTasks: true` and `receiverTaskTtlMs: getMCPTaskTtl(currentConfig)` where `currentConfig` is `configRef.current` in `ensureInspectorClient`. +- **Elicitation decline:** In the handler for ā€œdecline,ā€ if the elicitation has a `reject` (task-augmented), call it with the error so the server’s `tasks/result` receives an error and the task is marked failed; then call `elicitation.remove()` as usual. +- No other app change: existing listeners for `newPendingSample` and `newPendingElicitation` continue to work; for task-augmented requests the first response to the server is already sent (task ref), and when the user resolves we complete the task via the same `respond`/`reject` calls. + +--- + +## 4. Testing + +Receiver-task behavior is covered by **e2e tests** only: a full protocol driver where the test server sends `createMessage` or `elicit` with `params.task`, receives `{ task }`, then sends `tasks/list`, `tasks/get`, `tasks/result`, and optionally `tasks/cancel`, and asserts on responses. There are no unit tests that call receiver-task private methods. + +**Test fixture** +The fixture `createTaskTool` (in `core/test/test-server-fixtures.ts`) supports `receiverTaskTtl?: number`. When set, the tool’s createTask handler sends `sampling/createMessage` or `elicitation/create` with `params.task: { ttl }` to the client, receives `{ task }`, then polls the client with `tasks/get` until the task is terminal and fetches the payload via `tasks/result`. The tool’s result is that payload, so the test can assert end-to-end behavior. + +**E2E flow (e.g. sampling)** + +1. Server sends `sampling/createMessage` with `params.task` (e.g. `{ ttl: 5000 }`). +2. Client returns `{ task }` immediately; test asserts response shape and `task.taskId`. +3. Test obtains the pending sample (e.g. via `newPendingSample` event) and calls `respond(result)` with a known payload. +4. Server (fixture) calls `tasks/get` for that taskId until status is `completed`, then `tasks/result`; fixture uses that payload as the tool result. +5. Test asserts the tool result (from `callToolStream`) matches the payload passed to `respond(...)`. + +**Elicit variant** +Same idea with `elicit` and `params.task`; test drives `respond(result)` or decline (`reject(error)`); server’s `tasks/result` sees success or error. + +**Location** +`core/__tests__/inspectorClient.test.ts`, describe block ā€œReceiver tasks (e2e)ā€ — two tests: one for sampling, one for elicitation. + +--- + +## 5. Files changed (summary) + +| Area | File | Changes | +| --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Options / types | `core/mcp/inspectorClient.ts` | `receiverTasks`, `receiverTaskTtlMs` on InspectorClientOptions; `ReceiverTaskRecord`; `receiverTaskRecords`. | +| Constructor | `core/mcp/inspectorClient.ts` | When `receiverTasks` is true, add `tasks` to ClientCapabilities. | +| Helpers | `core/mcp/inspectorClient.ts` | `createReceiverTask`, `emitReceiverTaskStatus`, `upsertReceiverTask`, `getReceiverTask`, `listReceiverTasks`, `getReceiverTaskPayload`, `cancelReceiverTask`; `isTerminalTaskStatus`. | +| connect() | `core/mcp/inspectorClient.ts` | When `receiverTasks` is true: task-aware CreateMessage and Elicit handlers; ListTasks, GetTask, GetTaskPayload, CancelTask handlers. | +| disconnect() | `core/mcp/inspectorClient.ts` | Clear receiver task timeouts and `receiverTaskRecords`. | +| Elicitation | `core/mcp/elicitationCreateMessage.ts` | Optional `reject(error: Error)` (public method; set when task-augmented so App can reject the task payload on decline). | +| App | `web/src/App.tsx` | Pass `receiverTasks: true` and `receiverTaskTtlMs: getMCPTaskTtl(config)`; on elicitation decline, call `reject` when present. | +| Test fixtures | `core/test/test-server-fixtures.ts` | `createTaskTool` with `receiverTaskTtl` for e2e receiver flow. | diff --git a/docs/launcher-config-consolidation-plan.md b/docs/launcher-config-consolidation-plan.md new file mode 100644 index 000000000..aad7b146b --- /dev/null +++ b/docs/launcher-config-consolidation-plan.md @@ -0,0 +1,132 @@ +# Launcher and config consolidation plan + +## Goal + +- One **dedicated launcher** package under `launcher/` (implemented in `main.ts` with Commander, built to `build/main.js`) that only chooses which app to run and forwards argv; no config processing in the launcher. +- One **shared config processor** in **core** that turns MCP server/connection options into a runner config; all apps use it. **All existing config behavior must continue to work:** the processor must honor every supported server-config parameter. +- Each app (web, CLI, TUI) exposes a **runner** that accepts argv, does its own parsing and help, calls the shared processor for server config, and runs. Same behavior when invoked from the launcher or directly. + +--- + +## Current behavior + +### Entrypoints + +| User runs | What executes | Config handling | +| ---------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `mcp-inspector` (or `npm run web`) | `node cli/build/cli.js` (launcher) | Launcher parses argv, does `--config` / `--server` / mcp.json handling | +| `mcp-inspector --web` | Same launcher → **spawns** `node web/bin/start.js` | Launcher resolves config to Args, passes as CLI flags to child | +| `mcp-inspector --cli` | Same launcher → **spawns** `node cli/build/index.js` | Launcher resolves config to command+args+transport+cwd, passes as argv to child | +| `mcp-inspector --tui` | Same launcher → **spawns** `node tui/build/tui.js` | **No** launcher config: raw `process.argv.slice(2)` passed to TUI; TUI parses its own config file path and options | +| `npm run dev` | `node web/bin/start.js --dev` | **No** launcher; web parses argv only (no `--config`/`--server`), so no mcp.json unless you pass equivalent flags manually | +| `mcp-inspector-web` (from web workspace) | `node web/bin/start.js` | Same as above | +| `mcp-inspector-tui` (from TUI workspace) | `node tui/build/tui.js` | TUI parses argv; expects config file path and options | +| `mcp-inspector-cli` (from CLI workspace) | `node cli/build/index.js` | CLI parses argv; expects target (URL or command) + method flags; **no** `--config`/`--server` | + +So: + +- **Config from mcp.json** is only done in the launcher (`cli/src/cli.ts`): `loadConfigFile(configPath, serverName)` returns a single `ServerConfig`; `parseArgs()` then **merges that with all supported CLI options** (e.g. `--cwd`, `-e`, `--header`, args after `--`) and produces `Args` (command, args, transport, serverUrl, cwd, envArgs, etc.). So existing behavior is: file supplies base config; every supported param can override or extend it. +- **Web** never sees `--config` or `--server`; it receives the **resolved** params (e.g. `--transport stdio --cwd /path -- node script.js`). Web's `start.js` parses those flags and env (e.g. `MCP_INITIAL_*`) and passes a config object into the dev or prod client. +- **CLI (inspector-cli)** when spawned gets the same resolved target (command + args) as its positional arguments, plus `--transport`, `--cwd`, etc. It uses its own `parseArgs()` and `argsToMcpServerConfig()` (duplicated logic vs core's `argsToMcpServerConfig` in `core/mcp/node/config.ts`) to build `MCPServerConfig`. +- **TUI** does not use the launcher's config at all when started via `mcp-inspector --tui`; it gets raw argv and requires a config **file path** and supports multiple servers from that file via core's `loadMcpServersConfig()`. + +### Problems with the current design + +1. **Two processes for web and CLI** + Launcher spawns `node web/bin/start.js` or `node cli/build/index.js`. That means extra process overhead, harder debugging, and serialization of config via argv/env instead of passing a config object. + +2. **Config logic is split and duplicated** + - Launcher: custom `loadConfigFile(configPath, serverName)` and `parseArgs()` that understand `--config`/`--server`, single-server only. + - Core: `loadMcpServersConfig(configPath)` (full file) and `argsToMcpServerConfig(args)` (target + transport → `MCPServerConfig`). + - CLI (index.ts): its own `argsToMcpServerConfig()` (duplicate of core's). + - TUI: uses core's `loadMcpServersConfig`; different UX (config file path + multi-server). + - Web: no config file support; only resolved params via argv/env. + +3. **Direct launch is inconsistent** + - `npm run dev` or `mcp-inspector-web` cannot use `--config mcp.json --server demo`; they'd need to be extended to accept those flags and do the same resolution, or users must use the launcher. + - So "use one entrypoint" vs "run web (or TUI) directly" are at odds: direct run doesn't get launcher's config handling unless we duplicate or share it. + +4. **TUI is a special case** + TUI expects a **file path** and multiple servers; launcher is single-server and doesn't pass config file to TUI when using `--tui`. So `mcp-inspector --tui ./mcp.json` would pass `./mcp.json` as a raw arg; TUI would parse it. But launcher's `--config`/`--server` flow (resolve one server from file) is never used for TUI—the comment in code says "we'll integrate config later." + +--- + +## Design + +### 1. Launcher: dedicated package under `launcher/` + +- A **dedicated launcher package** lives under **`launcher/`** (new workspace, same pattern as `cli/`, `web/`, `tui/`). It is implemented in **`launcher/src/main.ts`** using **Commander** for argument parsing. The package has a build step that compiles to e.g. `launcher/build/main.js`; the root package `bin` for `mcp-inspector` points to that file. +- **Responsibility:** Only to choose which app runs and to forward argv. The launcher: + - Parses argv **only** to detect `--web`, `--cli`, or `--tui` (default when none specified, e.g. `--web`). + - If `-h` or `--help` is present and no mode flag is set, prints launcher help and exits. Launcher help shows **only** the mode options (`--web`, `--cli`, `--tui`) and a note that all other arguments are forwarded to the selected app. + - Does **not** parse, document, or process `--config`, `--server`, or any other server-config or app-specific options. + - Imports the chosen app package and calls its runner with **argv** in-process: `runWeb(process.argv)`, `runCli(process.argv)`, or `runTui(process.argv)`. No subprocess spawn. +- The launcher does **not** call the shared config processor. Config processing is done only inside the runners. + +### 2. Core: shared config processor + +- The **shared config processor** lives in **core** (e.g. `core/config/` or under `core/mcp/node/`). It is a **library**: it does not parse argv and does not display help. Callers (the runners) parse argv and pass in only the server-config subset. +- **Input:** A structured object of MCP server/connection options (e.g. config path + server name, or command/URL + transport, plus cwd, env, headers, etc.). +- **Output:** A single **`MCPServerConfig`** (the existing type in core that describes how to connect to one MCP server: transport, command/args or serverUrl, cwd, env, headers). +- **Behavior:** When config file + server are provided, load the file, resolve the named server, and merge with any overrides from the options object (CLI overrides win). When no file is provided, build runner config from the options object only (ad-hoc command/URL + transport, etc.). All existing config behavior is preserved. +- **Server-config parameters** (the processor accepts these when provided by the caller; the processor does not read argv itself): `--config` / config path, `--server` / server name; `-e` KEY=VALUE (env), `--transport`, `--server-url`, `--cwd`, `--header`; positional / args after `--` (command and args for stdio). +- **App-specific options** (e.g. `--dev`, `--method`, `--tool-name`) are **not** passed to the processor. Each runner parses argv, extracts the server-config subset, calls the processor, and handles its own options and help. If an app receives a param that does not apply (e.g. `--method` for web or TUI), that app treats it as an error. + +### 3. Runners: each app owns parsing, config, and help + +- Each app (web, CLI, TUI) exposes a **runner** function that accepts **argv** (e.g. `runWeb(argv)`, `runCli(argv)`, `runTui(argv)`). The runner is the single code path for that app whether it is invoked from the launcher or from the app's own entrypoint. +- **Each runner:** Parses argv (Commander or equivalent), handles `-h`/`--help` for **that app** (shows that app's full option list including server-config and app-specific options), extracts the server-config subset, calls the **core config processor** with that subset to get `MCPServerConfig`, then runs the app with that config plus any app-specific options (e.g. `--dev`, `--method`). +- Examples: `mcp-inspector --cli -h` → launcher invokes CLI runner with argv; CLI runner sees `-h` and prints CLI help. `mcp-inspector --cli --config mcp.json --server demo --method tools/list` → launcher invokes CLI runner with argv; CLI runner parses everything, calls core processor for server config, runs tools/list. `mcp-inspector-cli --config mcp.json -h` (direct) → app entrypoint calls `runCli(process.argv)`; same behavior. No spawn: when the launcher runs an app, it is the same process. + +### 4. Direct launch + +- Each app continues to have its own entrypoint (e.g. `web/bin/start.js`, `cli/build/index.js`, `tui/build/tui.js`). The entrypoint is a thin wrapper: it calls the app's runner with `process.argv` (e.g. `runWeb(process.argv)`). +- Direct launch and launcher-invoked launch use the **same** runner code; the only difference is whether the process was started by the user running the app binary or by the launcher calling the runner. Behavior is identical. + +### 5. Summary + +| Component | Location | Responsibility | +| -------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Launcher** | **`launcher/`** package (`src/main.ts`, Commander, build → `build/main.js`) | Parse only `--web`/`--cli`/`--tui` and `-h`. Show launcher help (mode options only). Call `runWeb(argv)` / `runCli(argv)` / `runTui(argv)` in-process. No config processing. Root `bin` points to `launcher/build/main.js`. | +| **Config processor** | **Core** (`core/config/` or `core/mcp/node/`) | Library: accept structured server-config options, return `MCPServerConfig`. No argv parsing, no help. Used only by runners. | +| **Runners** | **Web** (`web/`), **CLI** (`cli/`), **TUI** (`tui/`) | Parse argv, show app help, call core config processor with server-config subset, run app. Same behavior when called from launcher or from app entrypoint. | +| **App entrypoints** | Same (e.g. `web/bin/start.js`, `cli/build/index.js`, `tui/build/tui.js`) | Thin wrapper: call runner with `process.argv`. | + +### 6. Phasing + +1. **Core config module** + Add (or extend) a layer in core: e.g. `resolveServerConfig(options)` that accepts a structured object of server-config options (parsed by the caller from argv) and returns a single **`MCPServerConfig`** (the type already used by InspectorClient and transports). Deprecate/remove duplicate `argsToMcpServerConfig` in CLI in favor of this shared processor. + +2. **Web runner** + Refactor so that `runWeb(argv)` is the main API: it parses argv, calls core config processor for the server-config subset, handles `--dev` and `-h`, then runs. Entrypoint `web/bin/start.js` just calls `runWeb(process.argv)`. + +3. **CLI runner** + Refactor so that `runCli(argv)` is the main API: parses argv, calls core config processor for server config, handles `--method`, `-h`, etc., then runs. Entrypoint calls `runCli(process.argv)`. + +4. **TUI runner** + TUI already exports `runTui(args?)`; ensure it accepts argv and does its own parsing and core config processor use where applicable. Entrypoint calls `runTui(process.argv)`. + +5. **Launcher package (`launcher/`)** + Add a new workspace **`launcher/`** with `src/main.ts` using Commander to parse **only** `--web`/`--cli`/`--tui` and `-h`. Show launcher help for `mcp-inspector -h` (only those mode options). Dynamic import of the chosen app and call `runWeb(process.argv)` / `runCli(process.argv)` / `runTui(process.argv)`. No config processing; no spawn. Build to `launcher/build/main.js`; root package `bin` for `mcp-inspector` points to that file. Launcher package has Commander as a dependency. + +6. **Direct launch** + Each app's binary already calls its runner with argv, so direct run (e.g. `mcp-inspector-web`, `mcp-inspector-cli`) behaves identically to launcher-invoked run. + +--- + +## Open questions + +- **TUI multi-server:** TUI currently uses a full config file and multiple servers. Launcher is single-server. Do we want launcher to support "launch TUI with this one server from mcp.json" (same as web/CLI) or always pass through to TUI's own multi-server UX (config file path)? +- **Packaging:** With in-process require, the root package (or launcher package) must depend on web, CLI, and TUI (or have a way to load them). Today's workspace layout already has them; we need to ensure the launcher can import the runners (e.g. from `@modelcontextprotocol/inspector-web`, `@modelcontextprotocol/inspector-cli`, `@modelcontextprotocol/inspector-tui` or relative paths in monorepo). +- **Exit codes and signals:** When the launcher runs web in-process, the web process _is_ the launcher; SIGINT etc. are handled by one process. That can simplify cleanup. We should document expected exit codes for each runner. + +--- + +## References + +- Launcher: `cli/src/cli.ts` (parseArgs, loadConfigFile, runWeb, runCli, runTui). +- Web entry: `web/bin/start.js` (argv parsing, startDevClient / startProdClient). +- CLI entry: `cli/src/index.ts` (parseArgs, argsToMcpServerConfig, callMethod). +- TUI entry: `tui/tui.tsx` (runTui, Commander for config file + options). +- Core config: `core/mcp/node/config.ts` (loadMcpServersConfig, argsToMcpServerConfig). +- Todo: `docs/inspector-client-todo.md` (Misc: "Look at the launcher flow… Single launcher just routes to app…"). diff --git a/docs/mcp-apps-web-plan.md b/docs/mcp-apps-web-plan.md new file mode 100644 index 000000000..895601f9e --- /dev/null +++ b/docs/mcp-apps-web-plan.md @@ -0,0 +1,312 @@ +# MCP Apps support for Web - implementation plan + +This document outlines a detailed plan to add MCP Apps support to the web app. The plan is informed by **reading PR 1044** (main implementation), PR 1043 (tests), and PR 1075 (tool-result fix), and by the current client and web codebases. + +**Code locations:** On the main/v1 tree the web app lives in `client/` and the proxy/server in `server/`. On this branch the web app lives in `web/`. References to `client/` and `server/` in this document refer to the v1/main tree (e.g. PR 1044); the current app implementation is in `web/`. + +**As-built (Phase 1 completed):** Phase 1 is implemented. Differences from the original plan are noted in **Section 9 (As-built notes)**. Manual verification: all **19 MCP app servers** in `configs/mcpapps.json` have been verified to work in the web app. + +## Phases + +- **Phase 1 (initial):** Get to a working version quickly so we can verify MCP apps functionality. Use a **fixed sandbox port (6277)** and pass a **client proxy** from `InspectorClient.getAppRendererClient()` to mcp-ui (see **AppRendererClient** below). Accept the known limitation: when an app is open, mcp-ui's `setNotificationHandler` overwrites InspectorClient's, so Tools/Resources/Prompts list updates may stop until the app is closed—until Phase 2 multiplexing is implemented. No dynamic port, no sandbox API. +- **Phase 2 (follow-on):** Production-ready plumbing. **Dynamic port and on-demand sandbox server** (ephemeral server, bind to port 0 or on-demand start; expose sandbox URL via API). **Notification multiplexing:** use the existing **AppRendererClient** proxy’s `setNotificationHandler` interception to route app handlers to InspectorClient’s multiplexer (`addAppNotificationHandler`), so both InspectorClient and the app receive notifications. Phase 2 removes the single-handler limitation and avoids fixed-port conflicts. + +## 1. Context and references + +### 1.1 Current state + +- **Client package (reference; we are not modifying it):** Has full MCP Apps support: + - **AppsTab** - lists tools with `_meta.ui.resourceUri`, app selection, input form, open/close/maximize. + - **AppRenderer** - embeds the app UI via `@mcp-ui/client`'s `McpUiAppRenderer` with sandbox, tool input, and host callbacks. + - Sandbox URL comes from the MCP proxy: `${getMCPProxyAddress(config)}/sandbox`. + - Tab is always available; `listTools` is triggered when the Apps tab is active and server supports tools. +- **Web:** Uses InspectorClient + useInspectorClient; has Tools, Resources, Prompts, etc., but **no Apps tab** and no MCP Apps dependencies. + +### 1.2 Reference PRs (read and use to inform this plan) + +- **PR 1044** - **Main implementation PR.** "Add MCP Apps support to Inspector." Implements detection, listing, and interactive rendering of MCP apps with full bidirectional communication. Details from the PR: + - **New components:** AppsTab (detects/displays tools with `_meta.ui.resourceUri`), AppRenderer (full MCP App lifecycle, AppBridge integration). + - **Files added:** `client/src/components/AppsTab.tsx`, `AppRenderer.tsx`, their tests; **server/static/sandbox_proxy.html** - sandbox proxy refactored to match [basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host): nested iframe for security, `sandbox.ts` logic inlined in the HTML. Served on a **different port** via the proxy (client uses `getMCPProxyAddress(config)` + `/sandbox`). The **server** (Express) already has the `/sandbox` endpoint (rate-limited, no-cache) that serves this file; web only needs to point at the correct sandbox URL (same origin when web is served with that server, or configured base). + - **Files modified in 1044:** `client/App.tsx` (Apps tab integration, auto-fetch when tab active, resource wiring); **ListPane** - only show Clear button if `clearItems` is passed (AppsTab does not pass it); ListPane.test, AuthDebugger.test (relative imports); client package.json (ext-apps, mcp-ui/client); client jest.config.cjs (ES modules from ext-apps); **server/src/index.ts** (add `/sandbox` endpoint, serve sandbox_proxy.html, rate limiting); server package.json (shx, express-rate-limit, copy static to build). + - **Features (from PR):** App detection; auto-population when Apps tab becomes active; resource fetching for `ui://` via MCP; MCP-UI AppRenderer integration (client, tool name, resource content, host context); PostMessage transport (JSON-RPC iframe ↔ host); sandboxed rendering with configurable permissions; theme (light/dark); error handling. + - **Architecture (from PR):** Host: AppsTab → AppRenderer → MCP Client; AppBridge ↔ MCP Server; PostMessage transport; sandboxed iframe (MCP App UI). Use this when porting so web matches the same flow. + - **Tests in 1044:** AppsTab 18 tests (detection/filtering, grid, refresh, error, selection/deselection, dynamic tool list, resource content). AppRenderer 8 tests (loading, resource fetching, error states, iframe attributes, AppBridge init/connection, JSON/HTML parsing, permissions, lifecycle). PR 1043 added more; mirror coverage in web with Vitest. + - **PR discussion notes:** Console.logs were left in for debugging; remove or gate when porting. Nested iframe in sandbox addressed CodeQL XSS concerns. Some example servers (budget-allocator, pdf-server, etc.) didn't work initially; budget-allocator is fixed by PR 1075 (tool result). Advanced sandbox permissions may be a follow-up. +- **PR 1043** - Adds comprehensive tests for MCP Apps (AppsTab + AppRenderer). Use for test patterns and Jest config (transform for `@modelcontextprotocol/ext-apps` ESM); adapt to Vitest for web. +- **PR 1075** - Bug fix: Apps tab was missing **tool result** data. Apps received `ui/notifications/tool-input` but not `ui/notifications/tool-result`, breaking apps that depend on initial tool result (e.g. budget allocator). Fix: run tools/call when app/tool input changes, pass result into the iframe via `toolResult` (emits `ui/notifications/tool-result`), with AbortController + run-id for stale-result safety and error fallback (failed tools/call → error result with `isError: true`). **When porting AppRenderer to web, include this tool-result behavior.** + +### 1.3 Client dependencies (MCP Apps) + +- `@mcp-ui/client` - `AppRenderer` (McpUiAppRenderer), `McpUiHostContext`, `RequestHandlerExtra`. +- `@modelcontextprotocol/ext-apps` - `getToolUiResourceUri` from `app-bridge`; types `McpUiMessageRequest`, `McpUiMessageResult` (used by AppRenderer). + +### 1.4 MCP primitives, bridge, and InspectorClient + +**What MCP Apps use (ext-apps spec + @mcp-ui/client):** + +- **Primitives:** Standard MCP only. The View (iframe) asks the Host to run **resources/read** (including `ui://` URIs for the app HTML) and **tools/call**. The Host bridges by passing the **raw MCP Client** to McpUiAppRenderer; the library calls `client.request({ method: "resources/read", params: { uri } })` and `client.request({ method: "tools/call", params })` on that client. So the transport and primitives are the same connection InspectorClient already uses. +- **Events server → host:** There are no app-specific server notifications. The Host gets tool results from **responses** to tools/call and then pushes `ui/notifications/tool-input` / `ui/notifications/tool-result` to the View over postMessage. So "events" to the app are synthesized by the Host from data it already has. +- **Bidirectional bridge:** Yes. The client (host) must bridge comms back to the server: when the app calls a tool or reads a resource, the Host uses the same MCP connection to perform the request. Web passes **AppRendererClient** from `getAppRendererClient()` to mcp-ui (see 1.5). + +**Why we use AppRendererClient (and not the raw Client):** + +- The **SDK Client** allows only **one** `setNotificationHandler` per notification method. **InspectorClient** registers handlers in `connect()` for `notifications/tools/list_changed`, etc. **@mcp-ui/client** also registers for those same methods when McpUiAppRenderer mounts. If we passed the raw SDK Client into AppRenderer, mcp-ui's `setNotificationHandler` would overwrite InspectorClient's and list updates would break while an app is open. +- **Solution (implemented):** InspectorClient no longer exposes the raw client. It exposes **`getAppRendererClient()`**, which returns an **AppRendererClient**—a **proxy** that delegates to the internal MCP Client. The proxy **intercepts only `setNotificationHandler`**. In Phase 1 the interception is a pass-through (we can add behavior later). In **Phase 2** we will route intercepted `setNotificationHandler` calls to InspectorClient's multiplexer (`addAppNotificationHandler`) so both InspectorClient and the app receive notifications. Web receives `appRendererClient` from the hook and passes it to AppsTab/AppRenderer. + +**Phase 1 vs Phase 2** + +- **Phase 1 (as-built):** Web passes **`appRendererClient`** from `useInspectorClient` (which calls `inspectorClient.getAppRendererClient()`) to AppRenderer. The proxy is **cached** in InspectorClient so the same reference is returned for the lifetime of the connection—avoiding React effect loops. Known limitation: when an app is open, mcp-ui's `setNotificationHandler` still overwrites InspectorClient's (interception is pass-through until Phase 2), so Tools/Resources/Prompts lists may not update until the app is closed. +- **Phase 2:** Use the existing **AppRendererClient** proxy’s `setNotificationHandler` hook: instead of forwarding to the internal client, call **`inspectorClient.addAppNotificationHandler(schema, handler)`**. InspectorClient’s single SDK registration (in `connect()`) will dispatch to its own logic and to all handlers registered via `addAppNotificationHandler`. Web already passes the proxy; no change needed. Optional later: shared "is app tool" helper (e.g. wrap `getToolUiResourceUri`). + +### 1.5 AppRendererClient proxy and notification multiplexing + +**What we implemented:** InspectorClient exposes **`getAppRendererClient(): AppRendererClient | null`**. The return type **AppRendererClient** is a type alias for the MCP SDK `Client`; it denotes the app-renderer–scoped proxy, not the raw client. The hook exposes **`appRendererClient`** (not `client`); web passes **`appRendererClient`** to AppsTab and AppRenderer so it’s clear this is the proxy for the Apps tab only. + +**How the proxy works:** + +- The proxy is a **JavaScript `Proxy`** around InspectorClient’s internal MCP Client. It **forwards all property/method access** to the internal client (so `callTool`, `listTools`, `readResource`, `request`, etc. behave identically). +- **Only `setNotificationHandler` is intercepted:** the proxy’s `get` handler returns a wrapper that can add behavior before delegating. Currently the wrapper just forwards to the internal client (pass-through). **Phase 2:** change the wrapper to call **`inspectorClient.addAppNotificationHandler(schema, handler)`** instead of forwarding, so app handlers are registered in a list; InspectorClient’s existing SDK registration in `connect()` will be extended to also invoke every handler in that list. Result: both InspectorClient and the app receive list_changed (and any other notifications). +- The proxy is **cached** in InspectorClient (`appRendererClientProxy`). We create it once when first needed (when connected) and return the same instance until disconnect or reconnect. That keeps the reference stable across React renders and prevents effect loops in AppRenderer. + +**Phase 2 implementation (remaining):** (1) Expose **`addAppNotificationHandler(notificationSchema, handler)`** (and optionally **`removeAppNotificationHandler`**) on InspectorClient. (2) In the AppRendererClient proxy’s `setNotificationHandler` wrapper, call **`addAppNotificationHandler`** instead of forwarding to the internal client. (3) In `connect()`, when registering the single SDK handler per notification method, have that handler run InspectorClient’s existing logic and then call every handler registered via `addAppNotificationHandler` for that method. + +**Result:** Web already calls `inspectorClient.getAppRendererClient()` (via the hook) and passes `appRendererClient` to AppRenderer. Once Phase 2 multiplexing is wired, only the proxy’s `setNotificationHandler` implementation and InspectorClient’s dispatch logic need to change; no web or prop renames required. + +--- + +## 2. High-level approach + +- **Reuse behavior and structure** from the client's AppsTab and AppRenderer. +- **Add** the same npm deps to web (`@mcp-ui/client`, `@modelcontextprotocol/ext-apps`). +- **Implement** in web: AppsTab and AppRenderer (copy/adapt from client, including PR 1075 tool-result handling). +- **Phase 1:** Fixed sandbox port (6277); pass **AppRendererClient** from `getAppRendererClient()` (via hook as `appRendererClient`) to mcp-ui; wire Apps tab with fixed sandbox URL. +- **Phase 2:** Dynamic/on-demand sandbox server and sandbox URL API; notification multiplexing via the existing AppRendererClient’s `setNotificationHandler` interception; web already uses the proxy. +- **Tests:** Add web tests for AppsTab and AppRenderer (Vitest), mirroring client coverage where useful; optionally port or adapt client tests. + +--- + +## 3. Prerequisites and dependencies + +### 3.1 Web package.json + +- Add: + - `@mcp-ui/client` (match client version, e.g. `^6.0.0`). + - `@modelcontextprotocol/ext-apps` (e.g. `^1.0.0`). +- Ensure Vitest (and any bundler config) can load these if they ship ESM (see client's Jest transform for `@modelcontextprotocol` in PR 1043). + +### 3.2 Sandbox URL for web + +- **Spec requirement:** Host and Sandbox must be different origins. The sandbox cannot be same-origin with the web app. +- **Phase 1:** Use a **fixed port (6277)**. Server serves `server/static/sandbox_proxy.html` on port 6277 (e.g. same server as Inspector or a dedicated listener on 6277). Web constructs sandbox URL as `http://:6277` (or from config/base URL) and passes it to AppsTab. No API; no dynamic port. +- **Phase 2:** **Ephemeral sandbox server** - bind to port `0` (OS-assigned) or on-demand start. Expose sandbox base URL via API (e.g. `GET /api/sandbox-url`). Web obtains URL from API and passes to AppsTab. Enables multiple instances and avoids port conflicts. + +### 3.3 UI and utils already in web + +- Web already has: **ListPane**, **IconDisplay**, **DynamicJsonForm**, **Label**, **Checkbox**, **Select**, **Textarea**, **Input**, **Button**, **Alert**, **Tabs**. No need to copy these from client; use web's existing components and paths (e.g. `@/components/ui/...`, `@/utils/schemaUtils`, `@/utils/jsonUtils`). +- **ListPane (PR 1044):** In the client, ListPane was changed so the Clear button is only shown when a `clearItems` prop is passed. AppsTab does not pass `clearItems`, so the Apps list shows no Clear button. When porting, if web's ListPane currently always shows Clear, update it to match: only show Clear when `clearItems` is provided. + +--- + +## 4. Implementation tasks + +### 4.1 Add dependencies (web) + +- In `web/package.json`, add: + - `@mcp-ui/client` + - `@modelcontextprotocol/ext-apps` +- Run install; fix any type or build issues (e.g. Vite/Vitest handling of these packages). + +### 4.2 Sandbox URL (server + web) + +- **Phase 1:** **Server:** Ensure `server/static/sandbox_proxy.html` is served on **fixed port 6277** (e.g. add a dedicated HTTP server/listener on 6277 when the Inspector server starts, or serve sandbox on 6277 from the same process). Apply same security as current `/sandbox` (rate limiting, no-cache, referrer validation in HTML). **Web:** Construct sandbox URL as `http://:6277` using the web app's host (or a configured base). Pass it to AppsTab as `sandboxPath`. No API call. +- **Phase 2:** **Server:** Start an ephemeral sandbox server (bind to port `0`) or on-demand; expose URL via `GET /api/sandbox-url`. **Web:** Fetch sandbox URL from API after connect; pass to AppsTab. If API unavailable, show message or disable app iframe. +- **As-built:** The **web app is self-contained**; the legacy server package is not used. Sandbox is served by the web app itself: (1) **Prod:** `web/src/server.ts` (TypeScript) runs both the Hono app on 6274 and a second `createServer` listener on 6277 that serves GET `/sandbox` from `web/static/sandbox_proxy.html`. Same process, same lifecycle. (2) **Dev:** `web/vite.config.ts` plugin starts the sandbox HTTP server on 6277 in the same Node process as Vite. Sandbox HTML is a copy in `web/static/sandbox_proxy.html` (referrer validation in HTML unchanged). No rate limiting added in as-built. + +### 4.3 Port AppsTab to web + +- **Source:** `client/src/components/AppsTab.tsx`. +- **Target:** `web/src/components/AppsTab.tsx`. +- **Changes:** + - Replace client-specific imports with web paths: + - `@/components/ui/tabs`, `@/components/ui/button`, `@/components/ui/alert`, etc. (already in web). + - `@/utils/jsonUtils`, `@/utils/schemaUtils` (and any schema/param helpers like `generateDefaultValue`, `isPropertyRequired`, `normalizeUnionType`, `resolveRef`) - use web's equivalents; copy from client only if a helper is missing in web. + - Keep the same props interface in spirit: `sandboxPath`, `tools`, `listTools`, `error`, `appRendererClient`, `onNotification`. Types: `Tool[]`, `AppRendererClient | null` (from `getAppRendererClient()` via the hook), `ServerNotification`. + - Keep app detection: `getToolUiResourceUri` from `@modelcontextprotocol/ext-apps/app-bridge`; filter tools with `hasUIMetadata`. + - Keep layout and behavior: ListPane for app list, form for selected app input, AppRenderer when "Open App" is used, maximize/minimize, back to input. + - Remove or replace any client-only references (e.g. `getMCPProxyAddress`); use the new sandbox helper instead. +- **Console logging:** Client has `console.log("[AppsTab] Filtered app tools", ...)`. Prefer removing or gating behind dev/debug so production web stays quiet. + +### 4.4 Port AppRenderer to web (including PR 1075 behavior) + +- **Source:** `client/src/components/AppRenderer.tsx` **plus** the behavior from **PR 1075** (tool result forwarding). +- **Target:** `web/src/components/AppRenderer.tsx`. +- **Changes:** + - Imports: Use web paths for UI (`@/components/ui/alert`, `@/lib/hooks/useToast`), and keep `@mcp-ui/client` and `@modelcontextprotocol/ext-apps` (for types if needed). + - Props: Same as client: `sandboxPath`, `tool`, `appRendererClient` (type `AppRendererClient | null` from `getAppRendererClient()` via the hook), `toolInput`, `onNotification`. **Add** support for **tool result** (PR 1075): either a `toolResult` prop or an internal call to `callTool` and pass result into `McpUiAppRenderer` so the iframe receives `ui/notifications/tool-result`. Implement: + - When tool/toolInput (or initial mount) is ready, call MCP `tools/call` with the selected tool and current arguments (if the app expects initial result). + - Pass the result into the renderer as `toolResult` so the iframe gets `ui/notifications/tool-result`. + - Use AbortController + run-id (or similar) so that when the user switches app or restarts, stale results are ignored. + - On tools/call failure, send an error-shaped result (e.g. `isError: true`) to the app so the UI doesn't hang. + - Host context: Use `document.documentElement.classList.contains("dark")` for theme like client. + - Callbacks: `onOpenLink`, `onMessage` (toast), `onLoggingMessage` (forward to `onNotification`), `onError` (set local error state). + - If the client's AppRenderer was updated in a branch for PR 1075 and this repo's client doesn't have it yet, implement the tool-result logic from the PR description when porting. + +### 4.5 Wire Apps tab in web App.tsx + +- **Tab list:** Add an "Apps" tab (e.g. icon `AppWindow` from lucide-react) with `value="apps"`, placed similarly to client (e.g. after Tools). +- **validTabs:** In every place where `validTabs` is derived (e.g. hash sync and "originating tab" after sampling/requests), add `"apps"` so that: + - Navigating to `#apps` is valid when connected. + - When a request completes and restores the originating tab, `"apps"` can be restored. +- **listTools when Apps tab is active:** Add an effect similar to client: when connected and `activeTab === "apps"` and `serverCapabilities?.tools`, call `listTools()`. (As-built: we use `connectionStatus === "connected"` for the connection check.) This keeps the tools list (and thus app tools) up to date when the user opens the Apps tab. +- **Render AppsTab:** Inside the same Tabs content area as Resources/Prompts/Tools, add: + - `` containing ``. +- **AppsTab props:** + - **Phase 1 (as-built):** `sandboxPath` = fixed URL (e.g. `http://${window.location.hostname}:6277/sandbox`). `appRendererClient={appRendererClient}` where `appRendererClient` is from useInspectorClient (i.e. `getAppRendererClient()`). Accept that Tools/Resources/Prompts list updates may stop while an app is open until Phase 2 multiplexing. + - **Phase 2:** `sandboxPath={sandboxUrl}` from API (e.g. GET /api/sandbox-url). Same `appRendererClient` from hook; proxy’s `setNotificationHandler` interception will route to multiplexer so both InspectorClient and app receive notifications. + - Both phases: `tools={inspectorTools}`, `listTools={() => { clearError("tools"); listTools(); }}`, `error={errors.tools}`, `onNotification={(notification) => setNotifications(prev => [...prev, notification])}`. Reuse the same notifications state used elsewhere. + +### 4.6 Multiplexed notification handling (Phase 2 only) + +- **Design (see 1.5):** The **AppRendererClient** proxy is already implemented in InspectorClient: **`getAppRendererClient()`** returns a cached Proxy that forwards to the internal client and **intercepts `setNotificationHandler`**. Web already passes `appRendererClient` from the hook to AppRenderer. Phase 2 only needs to wire the interception to a multiplexer. +- **Tasks:** + 1. **InspectorClient:** Expose **`addAppNotificationHandler(notificationSchema, handler)`** and optionally **`removeAppNotificationHandler`**. In `connect()`, ensure the single SDK notification registration dispatches to InspectorClient's existing logic and then to every handler registered via `addAppNotificationHandler` for that method. + 2. **AppRendererClient proxy:** In the proxy’s `setNotificationHandler` wrapper (in `getAppRendererClient()`), call **`this.addAppNotificationHandler(schema, handler)`** instead of forwarding to the internal client. App handlers are then in the multiplexer list; InspectorClient’s SDK registration will invoke them. + 3. **Web:** No change; already passes `appRendererClient` from the hook. +- **Scope:** core (InspectorClient multiplexer API and proxy wrapper behavior). +- **Order:** Phase 2; after Phase 1 is working and we want to fix the list-update limitation and add dynamic sandbox. + +### 4.7 Optional: Shared helper for "app tool" detection + +- If we want a single place for "does this tool have app UI?", we could add in shared (e.g. `shared/mcp/appsUtils.ts` or similar) a function that re-exports or wraps `getToolUiResourceUri` (or implements the same check). Then client and web could both import from shared. Defer unless we want to reduce direct dependency on `@modelcontextprotocol/ext-apps` from both apps. + +--- + +## 5. Testing + +### 5.1 Unit tests in web (Vitest) + +- **AppsTab:** Add `web/src/components/__tests__/AppsTab.test.tsx`. Cover: + - No apps available message and `_meta.ui.resourceUri` hint. + - Filtering to only tools with UI metadata. + - Grid/list display, selection, open/close, back to input. + - Refresh (listTools). + - Error display. + - Mock AppRenderer and, if needed, `getToolUiResourceUri` (or use real ext-apps). +- **AppRenderer:** Add `web/src/components/__tests__/AppRenderer.test.tsx`. Cover: + - Waiting state when `appRendererClient` is null. + - Renders McpUiAppRenderer when client is ready; passes toolName, sandbox, hostContext, toolInput (and toolResult if added). + - onMessage → toast. + - Optional: mock tools/call and assert toolResult is passed through (for PR 1075 behavior). +- Use Vitest's equivalent of Jest's module mock for `@mcp-ui/client` and optionally `@modelcontextprotocol/ext-apps` so tests don't load real iframe/sandbox code. Align with how client's AppRenderer.test and AppsTab.test mock these (see client's `__tests__`). + +### 5.2 Integration / manual + +- After wiring: connect web to a server that exposes tools with `_meta.ui.resourceUri` (e.g. an MCP server that serves app UIs). Open Apps tab, select an app, open it, and confirm the iframe loads and receives tool-input and tool-result (e.g. budget allocator example from ext-apps). + +### 5.3 MCP servers for manual testing (ext-apps examples) + +The PRs reference example servers from the [ext-apps](https://github.com/modelcontextprotocol/ext-apps) repo for manual testing. Below is a compiled list with MCP server configs (stdio). Source: ext-apps README "Running the Examples" / "With MCP Clients". Inspector uses the same `mcpServers` structure (type `stdio`, `command`, `args`). + +**Priority from PRs:** (1) **budget-allocator** – validates tool-result (PR 1075); use as the primary manual test. (2) **pdf**, **transcript**, **video-resource** – mentioned as possibly needing advanced sandbox permissions or follow-ups; test after budget-allocator. + +**Recommended first test (budget-allocator):** + +```json +"budget-allocator": { + "type": "stdio", + "command": "npx", + "args": ["-y", "--silent", "--registry=https://registry.npmjs.org/", "@modelcontextprotocol/server-budget-allocator", "--stdio"] +} +``` + +**Other ext-apps example servers (same stdio pattern; replace package name in args):** + +| Server key | NPM package | +| --------------------- | -------------------------------------------------- | +| budget-allocator | @modelcontextprotocol/server-budget-allocator | +| pdf | @modelcontextprotocol/server-pdf | +| transcript | @modelcontextprotocol/server-transcript | +| video-resource | @modelcontextprotocol/server-video-resource | +| map | @modelcontextprotocol/server-map | +| threejs | @modelcontextprotocol/server-threejs | +| shadertoy | @modelcontextprotocol/server-shadertoy | +| sheet-music | @modelcontextprotocol/server-sheet-music | +| wiki-explorer | @modelcontextprotocol/server-wiki-explorer | +| cohort-heatmap | @modelcontextprotocol/server-cohort-heatmap | +| customer-segmentation | @modelcontextprotocol/server-customer-segmentation | +| scenario-modeler | @modelcontextprotocol/server-scenario-modeler | +| system-monitor | @modelcontextprotocol/server-system-monitor | +| basic-react | @modelcontextprotocol/server-basic-react | +| basic-vanillajs | @modelcontextprotocol/server-basic-vanillajs | + +For each: `"type": "stdio", "command": "npx", "args": ["-y", "--silent", "--registry=https://registry.npmjs.org/", "", "--stdio"]`. + +**How to run:** Add one or more entries to your MCP server config (Inspector config file or UI), then connect and open the Apps tab. To run from a local ext-apps clone, see the "Local Development" section in the [ext-apps README](https://github.com/modelcontextprotocol/ext-apps) (build and run from `examples/budget-allocator-server`, etc.). + +**As-built:** Inspector config files for manual testing live in **`configs/`**. **`configs/mcpapps.json`** contains 19 MCP app server entries. **Manual verification:** All 19 servers in `configs/mcpapps.json` have been verified to work in the web app (Apps tab, open app, load and interact). + +--- + +## 6. Order of work (suggested) + +**Phase 1 (working MCP apps sooner)** + +1. Add deps (4.1). +2. Sandbox on fixed port 6277 (4.2 Phase 1): server serves sandbox_proxy.html on 6277; web uses `http://:6277` as sandboxPath. +3. Port AppRenderer (4.4), including tool-result behavior from PR 1075, and add tests (5.1). +4. Port AppsTab (4.3) and add tests (5.1). +5. Wire Apps tab in App (4.5 Phase 1): add tab, validTabs, listTools effect; pass fixed sandbox URL and `appRendererClient` from useInspectorClient (getAppRendererClient()) to AppsTab. +6. Manual check with an MCP server that has app tools (5.2). Verify apps load and work; accept that list updates may stall while an app is open. + +**Phase 2 (release plumbing)** + +7. Ephemeral/dynamic sandbox server and sandbox URL API (4.2 Phase 2); web fetches sandbox URL from API. +8. Multiplexed notification handling (4.6): implement addAppNotificationHandler and wire the AppRendererClient proxy’s setNotificationHandler to it; web already passes appRendererClient. +9. Optional: shared app-tool helper (4.7) and cleanup (e.g. remove debug logs). + +--- + +## 7. Risks and mitigations + +- **Phase 1 - Fixed port 6277:** If port 6277 is already in use, sandbox will fail to start. Document the requirement; Phase 2 (dynamic port) avoids this. +- **Sandbox origin/CSP:** The sandbox runs on a different origin (fixed 6277 in Phase 1; random port in Phase 2). Ensure the sandbox HTML's referrer allowlist (e.g. in `sandbox_proxy.html`) includes the web app's origin when deployed; document if the allowlist must be configured per environment. +- **PR 1075 not in tree:** When porting AppRenderer to web, if the source we port from doesn't yet have the tool-result fix, implement it when porting AppRenderer so web doesn't ship without it (budget-allocator and similar apps depend on it). +- **ESM in tests:** If `@modelcontextprotocol/ext-apps` or `@mcp-ui/client` are ESM-only, configure Vitest (or Vite) to transform them like the client's Jest config (PR 1043) so tests run. +- **Known example gaps (from PR 1044):** Some ext-apps example servers did not work in the first cut (e.g. budget-allocator fixed by 1075; pdf-server, transcript-server, video-resource-server may need advanced sandbox permissions or other follow-ups). Plan for the same "first cut" scope; document known limitations if needed. + +--- + +## 8. Summary checklist + +**Phase 1** (all complete) + +- [x] Add `@mcp-ui/client` and `@modelcontextprotocol/ext-apps` to web (4.1). +- [x] Serve sandbox on fixed port 6277 (4.2 Phase 1); web uses fixed sandbox URL. As-built: web app serves its own sandbox on 6277 in the same process (see Section 9). +- [x] Port AppRenderer to web with tool-result support from PR 1075 (4.4). +- [x] Port AppsTab to web (4.3); remove or gate console.log (per PR 1044 discussion). +- [x] ListPane: only show Clear when `clearItems` passed (optional prop); AppsTab does not pass it (3.3 / PR 1044). +- [x] Add "Apps" tab and validTabs entries in web App; pass appRendererClient (from getAppRendererClient() via hook) and fixed sandbox URL (4.5 Phase 1). +- [x] Effect: listTools when activeTab === "apps" and server has tools (4.5). +- [x] Add Vitest tests for AppsTab and AppRenderer (5.1); parity with client test count (AppsTab 20 tests, AppRenderer 5 tests). +- [x] Manual test with app-capable servers (5.2): all 19 servers in `configs/mcpapps.json` verified in web app. + +**Phase 2** + +- [ ] Ephemeral/dynamic sandbox server and sandbox URL API (4.2 Phase 2); web consumes API. +- [ ] Notification multiplexer (4.6): addAppNotificationHandler + wire AppRendererClient’s setNotificationHandler to it; web already passes appRendererClient. +- [ ] Optional: shared app-tool helper (4.7) and cleanup. + +--- + +## 9. As-built notes (Phase 1) + +Summary of how Phase 1 was actually implemented where it differed from the plan. + +- **Sandbox (4.2):** The web app is self-contained. The legacy server package does not run. Sandbox is served by the web app on fixed port 6277 in the **same process**: (1) **Production:** `web/src/server.ts` (TypeScript) compiles to `dist/server.js`. It starts the Hono app on 6274 and a second `http.createServer` on 6277 that serves GET `/sandbox` and GET `/sandbox/` with `web/static/sandbox_proxy.html` (no-cache headers; referrer validation is in the HTML). (2) **Development:** The Vite plugin in `web/vite.config.ts` starts the same sandbox HTTP server on 6277 when the dev server runs. Sandbox HTML was copied into `web/static/sandbox_proxy.html`. Rate limiting was not added in the as-built implementation. +- **Web server in TypeScript:** The app server lives in `web/src/server.ts` (TypeScript), built with `tsc -p tsconfig.server.json` and emitted as `dist/server.js`. `web/bin/server.js` was removed. `bin/start.js` (the only remaining JS in bin) spawns `dist/server.js` for prod. +- **Sandbox URL in app:** `sandboxPath` is `http://${window.location.hostname}:6277/sandbox` (Phase 1 fixed URL). +- **ListPane:** `clearItems` is optional; the Clear button is only rendered when `clearItems` is provided. AppsTab does not pass `clearItems`. +- **AppRenderer tool-result (PR 1075):** On mount/update, when `appRendererClient`, `tool`, and `toolInput` are set, we call `appRendererClient.callTool({ name, arguments })` and pass the result to `McpUiAppRenderer` as `toolResult`. A run-id ref is used to ignore stale results; on failure we pass an error-shaped result so the app UI does not hang. +- **AppRendererClient and handler interception:** InspectorClient no longer exposes the raw MCP client. It exposes **`getAppRendererClient(): AppRendererClient | null`**. The hook returns **`appRendererClient`** (so naming is consistent in web). The AppRendererClient is a **cached** JavaScript Proxy around the internal client: same instance for the lifetime of the connection (cleared on disconnect and when creating a new client), so React dependency arrays stay stable and the Apps tab does not loop. The proxy forwards all methods; **only `setNotificationHandler` is intercepted**. The interceptor currently passes through to the internal client. Phase 2 will change it to call **`addAppNotificationHandler`** so both InspectorClient and the app receive notifications and list updates continue while an app is open. +- **Configs:** `configs/` holds Inspector config files for manual testing. `configs/mcpapps.json` has 19 MCP app server entries, `configs/mcp.json` has other sample servers. Root `mcp.json` is gitignored via `/mcp.json` (root only); configs in `configs/` are committed. +- **Manual verification:** All 19 MCP app servers in `configs/mcpapps.json` have been manually verified to work in the web app (connect, open Apps tab, select app, open and interact). diff --git a/docs/mcp-feature-tracker.md b/docs/mcp-feature-tracker.md new file mode 100644 index 000000000..e367b29ba --- /dev/null +++ b/docs/mcp-feature-tracker.md @@ -0,0 +1,49 @@ +# MCP Feature Implementation Across Projects + +Track MCP feature support across InspectorClient, Web v1, Web v1.5, and TUI. + +| Feature | InspectorClient | Web v1 | Web v1.5 | TUI | +| ------------------------------------------ | --------------- | ------- | -------- | --- | +| **Resources** | | | | | +| List resources | āœ… | āœ… | āœ… | āœ… | +| Read resource content | āœ… | āœ… | āœ… | āœ… | +| List resource templates | āœ… | āœ… | āœ… | āœ… | +| Read templated resources | āœ… | āœ… | āœ… | āœ… | +| Resource subscriptions | āœ… | āœ… | āœ… | āŒ | +| Resources listChanged notifications | āœ… | āœ… | āœ… | āŒ | +| Pagination (resources) | āœ… | āœ… | āœ… | āœ… | +| Pagination (resource templates) | āœ… | āœ… | āœ… | āœ… | +| **Prompts** | | | | | +| List prompts | āœ… | āœ… | āœ… | āœ… | +| Get prompt (no params) | āœ… | āœ… | āœ… | āœ… | +| Get prompt (with params) | āœ… | āœ… | āœ… | āœ… | +| Prompts listChanged notifications | āœ… | āœ… | āœ… | āŒ | +| Pagination (prompts) | āœ… | āœ… | āœ… | āœ… | +| **Tools** | | | | | +| List tools | āœ… | āœ… | āœ… | āœ… | +| Call tool | āœ… | āœ… | āœ… | āœ… | +| Tools listChanged notifications | āœ… | āœ… | āœ… | āŒ | +| Pagination (tools) | āœ… | āœ… | āœ… | āœ… | +| **Roots** | | | | | +| List roots | āœ… | āœ… | āœ… | āŒ | +| Set roots | āœ… | āœ… | āœ… | āŒ | +| Roots listChanged notifications | āœ… | āœ… | āœ… | āŒ | +| **Authentication** | | | | | +| OAuth 2.1 flow | āœ… | āœ… | āœ… | āœ… | +| OAuth: Static/Preregistered clients | āœ… | āœ… | āœ… | āœ… | +| OAuth: DCR (Dynamic Client Registration) | āœ… | āœ… | āœ… | āœ… | +| OAuth: CIMD (Client ID Metadata Documents) | āœ… | āŒ | āœ… | āœ… | +| OAuth: Guided Auth (step-by-step) | āœ… | āœ… | āœ… | āœ… | +| Custom headers | āœ… (config) | āœ… (UI) | āœ… (UI) | āŒ | +| **Advanced Features** | | | | | +| Sampling requests | āœ… | āœ… | āœ… | āŒ | +| Sampling with tools | āŒ | āŒ | āŒ | āŒ | +| Elicitation requests (form) | āœ… | āœ… | āœ… | āŒ | +| Elicitation requests (url) | āœ… | āŒ | āœ… | āŒ | +| Tasks (long-running operations) | āœ… | āœ… | āœ… | āŒ | +| Requestor task support | āœ… | āœ… | āœ… | āŒ | +| Completions (resource templates) | āœ… | āœ… | āœ… | āŒ | +| Completions (prompts with params) | āœ… | āœ… | āœ… | āŒ | +| Progress tracking | āœ… | āœ… | āœ… | āŒ | +| **Other** | | | | | +| HTTP request tracking | āœ… | āŒ | āœ… | āœ… | diff --git a/docs/oauth-inspectorclient-design.md b/docs/oauth-inspectorclient-design.md new file mode 100644 index 000000000..f1d907835 --- /dev/null +++ b/docs/oauth-inspectorclient-design.md @@ -0,0 +1,1335 @@ +# OAuth Support in InspectorClient - Design and Implementation Plan + +## Overview + +This document outlines the design and implementation plan for adding MCP OAuth 2.1 support to `InspectorClient`. The goal is to extract the general-purpose OAuth logic from the web client into the shared package and integrate it into `InspectorClient`, making OAuth available for CLI, TUI, and other InspectorClient consumers. + +**Code locations:** Paths like `client/src/lib/` and `client/src/utils/` in this document refer to the **legacy web app** (on the main/v1 tree the web app lives in `client/`; on this branch it lives in `web/`). The extraction source was the legacy client; the active app on this branch is in `web/`. + +**Important**: The web client OAuth code will remain in place and will not be modified to use the shared code at this time. Future migration options (using shared code directly, relying on InspectorClient, or a combination) should be considered in the design but not implemented. + +## Goals + +1. **Extract General-Purpose OAuth Logic**: Copy reusable OAuth components from `client/src/lib/` and `client/src/utils/` to `core/auth/` (leaving originals in place) +2. **Abstract Platform Dependencies**: Create interfaces for storage, navigation, and redirect URLs to support both browser and Node.js environments +3. **Integrate with InspectorClient**: Add OAuth support to `InspectorClient` with both direct and indirect (401-triggered) OAuth flow initiation +4. **Support All Client Identification Modes**: Support static/preregistered clients, DCR (Dynamic Client Registration), and CIMD (Client ID Metadata Documents) +5. **Enable CLI/TUI OAuth**: Provide a foundation for OAuth support in CLI and TUI applications +6. **Event-Driven Architecture**: Design OAuth flow to be notification/callback driven for client-side integration + +## Architecture + +### Current State + +The web client's OAuth implementation consists of: + +- **OAuth Client Providers** (`client/src/lib/auth.ts`): + - `InspectorOAuthClientProvider`: Standard OAuth provider for automatic flow + - `GuidedInspectorOAuthClientProvider`: Extended provider for guided flow that saves server metadata and uses guided redirect URL +- **OAuth State Machine** (`client/src/lib/oauth-state-machine.ts`): Step-by-step OAuth flow that breaks OAuth into discrete, manually-progressible steps +- **OAuth Utilities** (`client/src/utils/oauthUtils.ts`): Pure functions for parsing callbacks and generating state +- **Scope Discovery** (`client/src/lib/auth.ts`): `discoverScopes()` function +- **Storage Functions** (`client/src/lib/auth.ts`): SessionStorage-based storage helpers +- **UI Components**: + - `AuthDebugger.tsx`: Core OAuth UI providing both "Guided" (step-by-step) and "Quick" (automatic) flows + - `OAuthFlowProgress.tsx`: Visual progress indicator showing OAuth step status + - OAuth callback handlers (web-specific, not moving) + +**Note on "Guided" Mode**: The Auth Debugger (guided mode) is a **core feature** of the web client, not an optional debug tool. It provides: + +- **Guided Flow**: Manual step-by-step progression with full state visibility +- **Quick Flow**: Automatic progression through all steps +- **State Inspection**: Full visibility into OAuth state (tokens, metadata, client info, etc.) +- **Error Debugging**: Clear error messages and validation at each step + +This guided mode should be considered a core requirement for InspectorClient OAuth support, not a future enhancement. + +### Target Architecture + +``` +core/auth/ +ā”œā”€ā”€ storage.ts # Storage abstraction using Zustand with persistence +ā”œā”€ā”€ providers.ts # Abstract OAuth client provider base class +ā”œā”€ā”€ state-machine.ts # OAuth state machine (general-purpose logic) +ā”œā”€ā”€ utils.ts # General-purpose utilities +ā”œā”€ā”€ types.ts # OAuth-related types +ā”œā”€ā”€ discovery.ts # Scope discovery utilities +ā”œā”€ā”€ store.ts # Zustand store for OAuth state (vanilla, no React deps) +└── __tests__/ # Tests + +core/mcp/ +└── inspectorClient.ts # InspectorClient with OAuth integration + +core/react/ +└── auth/ # Optional: Shareable React hooks for OAuth state + └── hooks.ts # React hooks (useOAuthStore, etc.) - requires React peer dep + # Note: UI components cannot be shared between TUI (Ink) and web (DOM) + # Each client must implement its own OAuth UI components + +client/src/lib/ # Web client OAuth code (unchanged) +ā”œā”€ā”€ auth.ts +└── oauth-state-machine.ts +``` + +## Abstraction Strategy + +### 1. Storage Abstraction with Zustand + +**Storage Strategy**: Use Zustand with persistent middleware for OAuth state management. Zustand's vanilla API allows non-React usage (CLI), while React bindings enable UI integration (TUI, web client). + +**Zustand Store Structure**: + +```typescript +interface OAuthStoreState { + // Server-scoped OAuth data + servers: Record< + string, + { + tokens?: OAuthTokens; + clientInformation?: OAuthClientInformation; + preregisteredClientInformation?: OAuthClientInformation; + codeVerifier?: string; + scope?: string; + serverMetadata?: OAuthMetadata; + } + >; + + // Actions + setTokens: (serverUrl: string, tokens: OAuthTokens) => void; + getTokens: (serverUrl: string) => OAuthTokens | undefined; + clearServer: (serverUrl: string) => void; + // ... other actions +} +``` + +**Storage Implementations**: + +- **Browser**: Zustand store with `persist` middleware using `sessionStorage` adapter +- **Node.js**: Zustand store with `persist` middleware using file-based storage adapter +- **Memory**: Zustand store without persistence (for testing) + +**Storage Location for InspectorClient**: + +- Default: `~/.mcp-inspector/oauth/state.json` (single Zustand store file) +- Configurable via `InspectorClientOptions.oauth?.storagePath` + +**Benefits of Zustand**: + +- Vanilla API works without React (CLI support) +- React hooks available for UI components (TUI, web client) +- Built-in persistence middleware +- Type-safe state management +- Easier to backup/restore (one file) +- Small bundle size + +### 2. Redirect URL Abstraction + +**Interface**: + +```typescript +interface RedirectUrlProvider { + /** + * Returns the redirect URL for normal mode + */ + getRedirectUrl(): string; + + /** + * Returns the redirect URL for guided mode + */ + getDebugRedirectUrl(): string; +} +``` + +**Implementations**: + +- `BrowserRedirectUrlProvider`: + - Normal: `window.location.origin + "/oauth/callback"` + - Guided: `window.location.origin + "/oauth/callback/guided"` +- `LocalServerRedirectUrlProvider`: + - Constructor takes `port: number` parameter + - Normal: `http://localhost:${port}/oauth/callback` + - Guided: `http://localhost:${port}/oauth/callback/guided` +- `ManualRedirectUrlProvider`: + - Constructor takes `baseUrl: string` parameter + - Normal: `${baseUrl}/oauth/callback` + - Guided: `${baseUrl}/oauth/callback/guided` + +**Design Rationale**: + +- Both redirect URLs are available from the provider +- Both URLs are registered with the OAuth server during client registration (like web client) +- This allows switching between normal and guided modes without re-registering the client +- The provider's mode determines which URL is used for the current flow, but both are registered for flexibility + +### 3. Navigation Abstraction + +**Interface**: + +```typescript +interface OAuthNavigation { + redirectToAuthorization(url: URL): void | Promise; +} +``` + +**Implementations**: + +- `BrowserNavigation`: Sets `window.location.href` (for web client) +- `ConsoleNavigation`: Prints URL to console and waits for callback (for CLI/TUI) +- `CallbackNavigation`: Calls a provided callback function (for InspectorClient) + +### 4. OAuth Client Provider Abstraction + +**Base Class**: + +```typescript +abstract class BaseOAuthClientProvider implements OAuthClientProvider { + constructor( + protected serverUrl: string, + protected storage: OAuthStorage, + protected redirectUrlProvider: RedirectUrlProvider, + protected navigation: OAuthNavigation, + protected mode: "normal" | "guided" = "normal", // OAuth flow mode + ) {} + + // Abstract methods implemented by subclasses + abstract get scope(): string | undefined; + + // Returns the redirect URL for the current mode + get redirectUrl(): string { + return this.mode === "guided" + ? this.redirectUrlProvider.getDebugRedirectUrl() + : this.redirectUrlProvider.getRedirectUrl(); + } + + // Returns both redirect URIs (registered with OAuth server for flexibility) + get redirect_uris(): string[] { + return [ + this.redirectUrlProvider.getRedirectUrl(), + this.redirectUrlProvider.getDebugRedirectUrl(), + ]; + } + + abstract get clientMetadata(): OAuthClientMetadata; + + // Shared implementation for SDK interface methods + async clientInformation(): Promise { ... } + saveClientInformation(clientInformation: OAuthClientInformation): void { ... } + async tokens(): Promise { ... } + saveTokens(tokens: OAuthTokens): void { ... } + saveCodeVerifier(codeVerifier: string): void { ... } + codeVerifier(): string { ... } + clear(): void { ... } + redirectToAuthorization(authorizationUrl: URL): void { ... } + state(): string | Promise { ... } +} +``` + +**Implementations**: + +- `BrowserOAuthClientProvider`: Extends base, uses browser storage and navigation (for web client) +- `NodeOAuthClientProvider`: Extends base, uses Zustand store and console navigation (for InspectorClient/CLI/TUI) + +**Mode Selection**: + +- **Normal mode** (`mode: "normal"`): Provider uses `/oauth/callback` for the current flow +- **Guided mode** (`mode: "guided"`): Provider uses `/oauth/callback/guided` for the current flow +- Both URLs are registered with the OAuth server during client registration (allows switching modes without re-registering) +- The mode is determined when creating the provider - specify normal or debug and it "just works" +- Both callback handlers are mounted (one at `/oauth/callback`, one at `/oauth/callback/guided`) +- The handler behavior matches the provider's mode (normal handler auto-completes, debug handler shows code) + +**Client Identification Modes**: + +- **Static/Preregistered**: Uses `clientId` and optional `clientSecret` from config +- **DCR (Dynamic Client Registration)**: Falls back to DCR if no static client provided +- **CIMD (Client ID Metadata Documents)**: Uses `clientMetadataUrl` from config to enable URL-based client IDs (SEP-991) + +## Module Structure + +### `core/auth/store.ts` + +**Exports** (vanilla-only, no React dependencies): + +- `createOAuthStore()` - Factory function to create Zustand store +- `getOAuthStore()` - Vanilla API for accessing store (no React dependency) + +**Note**: React hooks (if needed) would be in `core/react/auth/hooks.ts` as an optional export that requires React as a peer dependency. + +**Store Implementation**: + +- Uses Zustand's `create` function with `persist` middleware +- Browser: Persists to `sessionStorage` via Zustand's `persist` middleware +- Node.js: Persists to file via custom storage adapter for Zustand's `persist` middleware +- Memory: No persistence (for testing) + +**Storage Adapter for Node.js**: + +- Custom Zustand storage adapter that uses Node.js `fs/promises` +- Stores single JSON file: `~/.mcp-inspector/oauth/state.json` +- Handles file creation, reading, and writing atomically + +### `core/auth/providers.ts` + +**Exports**: + +- `BaseOAuthClientProvider` abstract class +- `BrowserOAuthClientProvider` class (for web client, uses sessionStorage directly) +- `NodeOAuthClientProvider` class (for InspectorClient/CLI/TUI, uses Zustand store) + +**Key Methods**: + +- All SDK `OAuthClientProvider` interface methods +- Server-specific state management via Zustand store +- Token and client information management +- Support for `clientMetadataUrl` for CIMD mode + +### `core/auth/state-machine.ts` + +**Exports**: + +- `OAuthStateMachine` class +- `oauthTransitions` object (state transition definitions) +- `StateMachineContext` interface +- `StateTransition` interface + +**Changes from Current Implementation**: + +- Accepts abstract `OAuthClientProvider` instead of `DebugInspectorOAuthClientProvider` +- Removes web-specific dependencies (sessionStorage, window.location) +- General-purpose state transition logic + +### `core/auth/utils.ts` + +**Exports**: + +- `parseOAuthCallbackParams(location: string): CallbackParams` - Pure function +- `generateOAuthErrorDescription(params: CallbackParams): string` - Pure function +- `generateOAuthState(): string` - Uses `globalThis.crypto` or Node.js `crypto` module + +**Changes from Current Implementation**: + +- `generateOAuthState()` checks for `globalThis.crypto` first (browser), falls back to Node.js `crypto.randomBytes()` + +### `core/auth/types.ts` + +**Exports**: + +- `CallbackParams` type (from `oauthUtils.ts`) +- Re-export SDK OAuth types as needed + +### `core/auth/discovery.ts` + +**Exports**: + +- `discoverScopes(serverUrl: string, resourceMetadata?: OAuthProtectedResourceMetadata): Promise` + +**Note**: This is already general-purpose (uses only SDK functions), just needs to be moved. + +### `core/react/auth/` (Optional - Shareable React Hooks Only) + +**What Can Be Shared**: + +- `hooks.ts` - React hooks for accessing OAuth state: + - `useOAuthStore()` - Hook to access Zustand OAuth store + - `useOAuthTokens()` - Hook to get current OAuth tokens + - `useOAuthState()` - Hook to get current OAuth state machine state + - These hooks are pure logic - no rendering, so they work with both Ink (TUI) and DOM (web) + +**What Cannot Be Shared**: + +- **UI Components** (`.tsx` files with visual rendering) cannot be shared because: + - TUI uses **Ink** (terminal rendering) with components like ``, ``, etc. + - Web client uses **DOM** (browser rendering) with components like `
`, ``, etc. + - They have completely different rendering targets, styling systems, and component APIs +- Each client must implement its own OAuth UI components: + - TUI: `tui/src/components/OAuthFlowProgress.tsx` (using Ink components) + - Web: `client/src/components/OAuthFlowProgress.tsx` (using DOM/HTML components) + +## OAuth Guided Mode (Core Feature) + +### What is the Auth Debugger? + +The "Auth Debugger" (guided mode) in the web client is **not** an optional debug tool - it's a **core feature** that provides two modes of OAuth flow: + +1. **Guided Flow** (Step-by-Step): + - Breaks OAuth into discrete, manually-progressible steps + - User clicks "Next" to advance through each step + - Full state visibility at each step (metadata, client info, tokens, etc.) + - Allows inspection and debugging of OAuth flow + - Steps: `metadata_discovery` → `client_registration` → `authorization_redirect` → `authorization_code` → `token_request` → `complete` + +2. **Quick Flow** (Automatic): + - Automatically progresses through all OAuth steps + - Still uses the state machine internally + - Redirects to authorization URL automatically + - Returns to callback with authorization code + +### How It Works + +**Components**: + +- **`OAuthStateMachine`**: Manages step-by-step progression through OAuth flow +- **`GuidedInspectorOAuthClientProvider`** (shared: `GuidedNodeOAuthClientProvider`): Extended provider that: + - Uses guided redirect URL (`/oauth/callback/guided` instead of `/oauth/callback`) + - Saves server OAuth metadata to storage for UI display + - Provides `getServerMetadata()` and `saveServerMetadata()` methods +- **`AuthGuidedState`**: Comprehensive state object tracking all OAuth data: + - Current step (`oauthStep`) + - OAuth metadata, client info, tokens + - Authorization URL, code, errors + - Resource metadata, validation errors + +**State Machine Steps** (Detailed): + +1. **`metadata_discovery`**: **RFC 8414 Discovery** - Client discovers authorization server metadata + - Always client-initiated (never uses server-provided metadata from MCP capabilities) + - Calls SDK `discoverOAuthProtectedResourceMetadata()` which makes HTTP request to `/.well-known/oauth-protected-resource` + - Calls SDK `discoverAuthorizationServerMetadata()` which makes HTTP request to `/.well-known/oauth-authorization-server` + - The SDK methods handle the actual HTTP requests to well-known endpoints + - Discovery Flow: + 1. Attempts to discover resource metadata from the MCP server URL + 2. If resource metadata contains `authorization_servers`, uses the first one; otherwise defaults to MCP server base URL + 3. Discovers OAuth authorization server metadata from the determined authorization server URL + 4. Uses discovered metadata for client registration and authorization +2. **`client_registration`**: **Registers client** (static, DCR, or CIMD) + - First tries preregistered/static client information (from config) + - Falls back to Dynamic Client Registration (DCR) if no static client available + - If `clientMetadataUrl` is provided, uses CIMD (Client ID Metadata Documents) mode + - Implementation pattern: + ```typescript + // Try Static client first, with DCR as fallback + let fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + context.provider.saveClientInformation(fullInformation); + } + ``` +3. **`authorization_redirect`**: Generates authorization URL with PKCE + - Calls SDK `startAuthorization()` which generates PKCE code challenge + - Builds authorization URL with all required parameters + - Saves code verifier for later token exchange +4. **`authorization_code`**: User provides authorization code (manual entry or callback) + - Validates authorization code input + - In guided mode, waits for user to enter code or receive via callback +5. **`token_request`**: Exchanges code for tokens + - Calls SDK `exchangeAuthorization()` with authorization code and code verifier + - Receives OAuth tokens (access_token, refresh_token, etc.) + - Saves tokens to storage +6. **`complete`**: Final state with tokens + - OAuth flow complete + - Tokens available for use in requests + +**Why It's Core**: + +- Provides transparency into OAuth flow (critical for debugging) +- Allows manual intervention at each step +- Shows full OAuth state (metadata, client info, tokens) +- Essential for troubleshooting OAuth issues +- Users expect this level of visibility in a developer tool + +**InspectorClient Integration**: + +- InspectorClient should support both automatic and guided modes +- Guided mode should expose state machine state via events/API +- CLI/TUI can use guided mode for step-by-step OAuth flow +- State machine should be part of initial implementation, not a future enhancement + +### OAuth Mode Implementation Details + +#### DCR (Dynamic Client Registration) Support + +**Behavior**: + +- āœ… Tries preregistered/static client info first (from Zustand store, set via config) +- āœ… Falls back to DCR via SDK `registerClient()` if no static client is found +- āœ… Client information is stored in Zustand store after registration + +**Storage**: + +- Preregistered clients: Stored in Zustand store as `preregisteredClientInformation` +- Dynamically registered clients: Stored in Zustand store as `clientInformation` +- The `clientInformation()` method checks preregistered first, then dynamic + +#### RFC 8414 Authorization Server Metadata Discovery + +**Behavior**: + +- āœ… Always initiates discovery client-side (never uses server-provided metadata from MCP capabilities) +- āœ… Discovers resource metadata from `/.well-known/oauth-protected-resource` via SDK `discoverOAuthProtectedResourceMetadata()` +- āœ… Discovers OAuth authorization server metadata from `/.well-known/oauth-authorization-server` via SDK `discoverAuthorizationServerMetadata()` +- āœ… No code path uses server-provided metadata from MCP server capabilities +- āœ… SDK methods handle the actual HTTP requests to well-known endpoints + +**Discovery Flow**: + +1. Attempts to discover resource metadata from the MCP server URL +2. If resource metadata contains `authorization_servers`, uses the first one; otherwise defaults to MCP server base URL +3. Discovers OAuth authorization server metadata from the determined authorization server URL +4. Uses discovered metadata for client registration and authorization + +**Note**: This is RFC 8414 discovery (client discovering server endpoints), not CIMD. CIMD is a separate concept (server discovering client information via URL-based client IDs). + +#### CIMD (Client ID Metadata Documents) Support + +**Status**: āœ… **Supported** (new in InspectorClient, not in current web client) + +**What CIMD Is**: + +- CIMD (Client ID Metadata Documents, SEP-991) is the DCR replacement introduced in the November 2025 MCP spec +- The client publishes its metadata at a URL (e.g., `https://inspector.app/.well-known/oauth-client-metadata`) +- That URL becomes the `client_id` (instead of a random string from DCR) +- The authorization server fetches that URL to discover client information (name, redirect_uris, etc.) +- This is "reverse discovery" - the server discovers the client, not the client discovering the server + +**How InspectorClient Supports CIMD**: + +- User provides `clientMetadataUrl` in OAuth config +- `NodeOAuthClientProvider` sets `clientMetadataUrl` in `clientMetadata` +- SDK checks for CIMD support and uses URL-based client ID if supported +- Falls back to DCR if authorization server doesn't support CIMD + +**What's Required for CIMD**: + +1. Publish client metadata at a publicly accessible URL +2. Set `clientMetadataUrl` in OAuth config +3. The authorization server must support `client_id_metadata_document_supported: true` + +### OAuth Flow Descriptions + +#### Automatic Flow (Quick Mode) + +1. **Configuration**: User provides OAuth config (clientId, clientSecret, scope, clientMetadataUrl) via `InspectorClientOptions` or `setOAuthConfig()` +2. **Storage**: Config saved to Zustand store as `preregisteredClientInformation` (if static client provided) +3. **Initiation**: User calls `authenticate()` (or `authenticateGuided()` for guided mode). We do not auto-initiate on 401; callers authenticate first, then connect. +4. **SDK Handles**: + - Authorization server metadata discovery (RFC 8414 - always client-initiated) + - Client registration (static, DCR, or CIMD based on config) + - Authorization redirect (generates PKCE challenge, builds authorization URL) +5. **Navigation**: Authorization URL dispatched via `oauthAuthorizationRequired` event +6. **User Action**: User navigates to authorization URL (via callback handler, browser open, or manual navigation) +7. **Callback**: Authorization server redirects to callback URL with authorization code +8. **Processing**: User provides authorization code via `completeOAuthFlow()` +9. **Token Exchange**: SDK exchanges code for tokens (using stored code verifier) +10. **Storage**: Tokens saved to Zustand store +11. **Connect**: User calls `connect()`. Transport is created with `authProvider` (tokens in storage). SDK injects tokens and handles 401 (auth, retry) inside the transport. We do not retry connect or requests after OAuth; the transport does. + +#### Guided Flow (Step-by-Step Mode) + +1. **Initiation**: User calls `authenticateGuided()` to begin guided flow +2. **State Machine**: `OAuthStateMachine` executes steps manually +3. **Step Control**: Each step can be viewed and manually progressed via `proceedOAuthStep()` +4. **State Visibility**: Full OAuth state available via `getOAuthState()` and `oauthStepChange` events +5. **Events**: `oauthStepChange` event dispatched on each step transition with current state + - Event detail includes: `step`, `previousStep`, and `state` (partial state update) + - UX layer can listen to update UI, enable/disable buttons, show step-specific information +6. **Authorization**: Authorization URL generated and dispatched via `oauthAuthorizationRequired` event +7. **Code Entry**: Authorization code can be entered manually or received via callback +8. **Completion**: `oauthComplete` event dispatched, full state visible, tokens stored in Zustand store + +## InspectorClient Integration + +### New Options + +```typescript +export interface InspectorClientOptions { + // ... existing options ... + + /** + * OAuth configuration + */ + oauth?: { + /** + * Preregistered client ID (optional, will use DCR if not provided) + * If clientMetadataUrl is provided, this is ignored (CIMD mode) + */ + clientId?: string; + + /** + * Preregistered client secret (optional, only if client requires secret) + * If clientMetadataUrl is provided, this is ignored (CIMD mode) + */ + clientSecret?: string; + + /** + * Client metadata URL for CIMD (Client ID Metadata Documents) mode + * If provided, enables URL-based client IDs (SEP-991) + * The URL becomes the client_id, and the authorization server fetches it to discover client metadata + */ + clientMetadataUrl?: string; + + /** + * OAuth scope (optional, will be discovered if not provided) + */ + scope?: string; + + /** + * Redirect URL for OAuth callback (required for OAuth flow) + * For CLI/TUI, this should be a local server URL or manual callback URL + */ + redirectUrl?: string; + + /** + * Storage path for OAuth data (default: ~/.mcp-inspector/oauth/) + */ + storagePath?: string; + }; +} +``` + +### New Methods + +```typescript +class InspectorClient { + // OAuth configuration + setOAuthConfig(config: { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; // For CIMD mode + scope?: string; + redirectUrl?: string; + }): void; + + // OAuth flow initiation (normal mode) + /** + * Initiates OAuth flow (user-initiated or 401-triggered). Both paths use this method. + * Returns the authorization URL. Dispatches 'oauthAuthorizationRequired' event. + */ + async authenticate(): Promise; + + /** + * Completes OAuth flow with authorization code + * @param authorizationCode - Authorization code from OAuth callback + * Dispatches 'oauthComplete' event on success + * Dispatches 'oauthError' event on failure + */ + async completeOAuthFlow(authorizationCode: string): Promise; + + // OAuth state management + /** + * Gets current OAuth tokens (if authorized) + */ + getOAuthTokens(): OAuthTokens | undefined; + + /** + * Clears OAuth tokens and client information + */ + clearOAuthTokens(): void; + + /** + * Checks if client is currently OAuth authorized + */ + isOAuthAuthorized(): boolean; + + /** + * Initiates OAuth flow in guided mode (step-by-step, state machine). + * Returns the authorization URL. Dispatches 'oauthAuthorizationRequired' and 'oauthStepChange' events. + */ + async authenticateGuided(): Promise; + + // Guided mode state management + /** + * Get current OAuth state machine state (for guided mode) + * Returns undefined if not in guided mode + */ + getOAuthState(): AuthGuidedState | undefined; + + /** + * Get current OAuth step (for guided mode) + * Returns undefined if not in guided mode + */ + getOAuthStep(): OAuthStep | undefined; + + /** + * Manually progress to next step in guided OAuth flow + * Only works when in guided mode + * Dispatches 'oauthStepChange' event on step transition + */ + async proceedOAuthStep(): Promise; +} +``` + +### OAuth Flow Initiation + +**Two Modes of Initiation**: + +1. **Normal Mode** (User-Initiated): + - User calls `client.authenticate()` explicitly + - Uses SDK's `auth()` function internally + - Returns authorization URL + - Dispatches `oauthAuthorizationRequired` event + - Client-side (CLI/TUI) listens for events and handles navigation + - User completes OAuth (e.g. via callback), then calls `completeOAuthFlow(code)`, then `connect()`. The transport uses `authProvider` to inject tokens; the SDK handles 401 (auth, retry) internally. We do not automatically retry connect or requests after OAuth. + +2. **Guided Mode** (User-Initiated): + - User calls `client.authenticateGuided()` explicitly + - Uses state machine for step-by-step control + - Dispatches `oauthStepChange` events as flow progresses + - Returns authorization URL + - Dispatches `oauthAuthorizationRequired` event + - Client-side listens for events and handles navigation + - Same flow as normal: complete OAuth, then `connect()`. + +**Event-Driven Architecture**: + +```typescript +// InspectorClient dispatches events for OAuth flow +this.dispatchTypedEvent("oauthAuthorizationRequired", { + url: authorizationUrl, +}); + +this.dispatchTypedEvent("oauthComplete", { tokens }); +this.dispatchTypedEvent("oauthError", { error }); + +// InspectorClient dispatches events for guided flow +this.dispatchTypedEvent("oauthStepChange", { + step: OAuthStep, + previousStep?: OAuthStep, + state: Partial +}); + +// Client-side (CLI/TUI) listens for events +client.addEventListener("oauthAuthorizationRequired", (event) => { + const { url } = event.detail; + // Handle navigation (print URL, open browser, etc.) + // Wait for user to provide authorization code + // Call client.completeOAuthFlow(code) +}); + +// For guided mode, listen for step changes +client.addEventListener("oauthStepChange", (event) => { + const { step, state } = event.detail; + // Update UI to show current step and state + // Enable/disable "Continue" button based on step +}); +``` + +**Event-Driven Architecture**: + +- InspectorClient dispatches `oauthAuthorizationRequired` events +- Callers are responsible for registering event listeners to handle the authorization URL +- CLI/TUI applications should register listeners to display the URL (e.g., print to console, show in UI) +- No default console output - callers must explicitly handle events + +**401 Error Handling (legacy; see authProvider migration below)**: + +InspectorClient previously detected 401 in `connect()` and request methods, called `authenticate()`, stored a pending request, and retried after OAuth. This custom logic has been **removed**. 401 handling is now delegated to the SDK transport via `authProvider`. + +### Token Injection and authProvider (Current Implementation) + +**Integration Point**: For HTTP-based transports (SSE, streamable-http), we pass an **`authProvider`** (`OAuthClientProvider`) into `createTransport`. The SDK injects tokens and handles 401 via the provider; we do not manually add `Authorization` headers or detect 401. + +- **Transport creation**: All transport creation happens in **`connect()`** (single place for create, wrap, attach). When OAuth is configured, we create a provider via `createOAuthProvider("normal" | "guided")` and pass it as `authProvider` to `createTransport`; the provider is created async there. +- **Flow**: Callers **authenticate first**, then connect. Run `authenticate()` or `authenticateGuided()`, complete OAuth with `completeOAuthFlow(code)`, then call `connect()`. The transport uses `authProvider` to inject tokens; the SDK handles 401 (auth, retry) inside the transport. +- **No connect-time 401 retry**: We do not catch 401 on `connect()` or retry. If `connect()` is called without tokens, the transport/SDK may throw (e.g. `Unauthorized`). Callers must run `authenticate()` (or guided flow), then retry `connect()`. +- **Request methods**: We no longer wrap `listTools`, `listResources`, etc. with 401 detection or retry. The transport handles 401 for all requests when `authProvider` is used. +- **Removed**: `getOAuthToken` callback, `createOAuthFetchWrapper`, `is401Error`, `handleRequestWithOAuth`, `pendingOAuthRequest`, and connect-time 401 catch block. + +## Implementation Plan + +### Phase 1: Extract and Abstract OAuth Components + +**Goal**: Copy general-purpose OAuth code to shared package with abstractions (leaving web client code unchanged) + +1. **Create Zustand Store** (`core/auth/store.ts`) + - Install Zustand dependency (with persist middleware support) + - Create `createOAuthStore()` factory function + - Implement browser storage adapter (sessionStorage) for Zustand persist + - Implement file storage adapter (Node.js fs) for Zustand persist + - Export vanilla API (`getOAuthStore()`) only (no React dependencies) + - React hooks (if needed) would be in separate `core/react/auth/hooks.ts` file + - Add `getServerSpecificKey()` helper + +2. **Create Redirect URL Abstraction** (`core/auth/providers.ts` - part 1) + - Define `RedirectUrlProvider` interface with `getRedirectUrl()` and `getDebugRedirectUrl()` methods + - Implement `BrowserRedirectUrlProvider` (returns normal and debug URLs based on `window.location.origin`) + - Implement `LocalServerRedirectUrlProvider` (constructor takes `port`, returns normal and debug URLs) + - Implement `ManualRedirectUrlProvider` (constructor takes `baseUrl`, returns normal and debug URLs) + - **Key**: Both URLs are available, both are registered with OAuth server, mode determines which is used for current flow + +3. **Create Navigation Abstraction** (`core/auth/providers.ts` - part 2) + - Define `OAuthNavigation` interface + - Implement `BrowserNavigation` + - Implement `ConsoleNavigation` + - Implement `CallbackNavigation` + +4. **Create Base OAuth Provider** (`core/auth/providers.ts` - part 3) + - Create `BaseOAuthClientProvider` abstract class + - Implement shared SDK interface methods + - Move storage, redirect URL, and navigation logic to base class + - Add support for `clientMetadataUrl` (CIMD mode) + +5. **Create Provider Implementations** (`core/auth/providers.ts` - part 4) + - Create `BrowserOAuthClientProvider` (extends base, uses sessionStorage directly - for web client reference) + - Create `NodeOAuthClientProvider` (extends base, uses Zustand store - for InspectorClient/CLI/TUI) + - Support all three client identification modes: static, DCR, CIMD + +6. **Copy OAuth Utilities** (`core/auth/utils.ts`) + - Copy `parseOAuthCallbackParams()` from `client/src/utils/oauthUtils.ts` + - Copy `generateOAuthErrorDescription()` from `client/src/utils/oauthUtils.ts` + - Adapt `generateOAuthState()` to support both browser and Node.js + +7. **Copy OAuth State Machine** (`core/auth/state-machine.ts`) + - Copy `OAuthStateMachine` class from `client/src/lib/oauth-state-machine.ts` + - Copy `oauthTransitions` object + - Update to use abstract `OAuthClientProvider` instead of `DebugInspectorOAuthClientProvider` + +8. **Copy Scope Discovery** (`core/auth/discovery.ts`) + - Copy `discoverScopes()` from `client/src/lib/auth.ts` + +9. **Create Types Module** (`core/auth/types.ts`) + - Copy `CallbackParams` type from `client/src/utils/oauthUtils.ts` + - Re-export SDK OAuth types as needed + +### Phase 2: (Skipped - Web Client Unchanged) + +**Note**: Web client OAuth code remains in place and is not modified at this time. Future migration options: + +- Option A: Web client uses shared auth code directly +- Option B: Web client relies on InspectorClient for OAuth +- Option C: Hybrid approach (some components use shared code, others use InspectorClient) + +These options should be considered in the design but not implemented now. + +### Phase 3: Integrate OAuth into InspectorClient + +**Goal**: Add OAuth support to InspectorClient with both direct and indirect initiation + +1. **Add OAuth Options to InspectorClientOptions** + - Add `oauth` configuration option with support for `clientMetadataUrl` (CIMD) + - Define OAuth configuration interface + - Support all three client identification modes + +2. **Add OAuth Provider to InspectorClient** + - Store OAuth config + - Create `NodeOAuthClientProvider` instances on-demand based on mode (lazy initialization) + - Normal mode provider created by default (for automatic flows) + - Guided mode provider created when `authenticateGuided()` is called + - Initialize Zustand store for OAuth state + - **Important**: Both redirect URLs are registered with OAuth server (allows switching modes without re-registering) + - Both callback handlers are mounted (normal at `/oauth/callback`, guided at `/oauth/callback/guided`) + - The provider's mode determines which URL is used for the current flow + +3. **Implement OAuth Methods** + - Implement `setOAuthConfig()` (supports clientMetadataUrl for CIMD) + - Implement `authenticate()` (direct and 401-triggered initiation, uses normal-mode provider) + - Implement `completeOAuthFlow()` + - Implement `getOAuthTokens()` + - Implement `clearOAuthTokens()` + - Implement `isOAuthAuthorized()` + - Implement guided mode state management methods: + - `getOAuthState()` - Get current OAuth state machine state (returns undefined if not in guided mode) + - `getOAuthStep()` - Get current OAuth step (returns undefined if not in guided mode) + - `proceedOAuthStep()` - Manually progress to next step (only works in guided mode, dispatches `oauthStepChange` event) + - **Note**: Guided mode is initiated via `authenticateGuided()`, which creates a provider with `mode="guided"` and initiates the flow + - **Note**: When creating `NodeOAuthClientProvider`, pass the `mode` parameter. Both redirect URLs are registered, but the provider uses the URL matching its mode for the current flow. + +4. **~~Add 401 Error Detection~~** (removed in authProvider migration) + - We no longer use `is401Error()` or detect 401 in connect/request methods. The transport handles 401 via `authProvider`. + +5. **Add OAuth Flow Initiation (User-Initiated Only)** + - User calls `authenticate()` or `authenticateGuided()` first, then `completeOAuthFlow(code)`, then `connect()`. We do not catch 401 or retry; the transport uses `authProvider` for token injection and 401 handling. + +6. **Add Guided Mode** + - Implement `authenticateGuided()` for step-by-step OAuth flow + - Create provider with `mode="guided"` when `authenticateGuided()` is called + - Dispatch `oauthAuthorizationRequired` and `oauthStepChange` events as state machine progresses + +7. **Add Token Injection (via authProvider)** + - For HTTP-based transports with OAuth, pass `authProvider` into `createTransport`. The SDK injects tokens and handles 401. We do not manually add `Authorization` headers. All transport creation happens in `connect()`. + - Refresh tokens if expired (future enhancement) – handled by SDK/authProvider when supported. + +8. **Add OAuth Events** + - Add `oauthAuthorizationRequired` event (dispatches authorization URL, mode, optional originalError) + - Add `oauthComplete` event (dispatches tokens) + - Add `oauthError` event (dispatches error) + - Add `oauthStepChange` event (dispatches step, previousStep, state) - for guided mode + - All events are event-driven for client-side integration + - Callers must register event listeners to handle `oauthAuthorizationRequired` events + +### Phase 4: Testing + +**Goal**: Comprehensive testing of OAuth support + +1. **Unit Tests for Shared OAuth Components** + - Test storage adapters (Browser, Memory, File) + - Test redirect URL providers + - Test navigation handlers + - Test OAuth utilities + - Test state machine transitions + - Test scope discovery + +2. **Integration Tests for InspectorClient OAuth** + - Test OAuth configuration + - Test 401 error detection and OAuth flow initiation + - Test token injection in HTTP transports + - Test OAuth flow completion + - Test token storage and retrieval + - Test OAuth error handling + +3. **End-to-End Tests with OAuth Test Server** + - Test full OAuth flow with test server (see "OAuth Test Server Infrastructure" below) + - Test static/preregistered client mode + - Test DCR (Dynamic Client Registration) mode + - Test CIMD (Client ID Metadata Documents) mode + - Test scope discovery + - Test token refresh (if supported) + - Test OAuth cleanup + - Test 401 error handling and automatic retry + +4. **Web Client Regression Tests** + - Verify all existing OAuth tests still pass + - Test normal OAuth flow + - Test debug OAuth flow + - Test OAuth callback handling + +## OAuth Test Server Infrastructure + +### Overview + +OAuth testing requires a full OAuth 2.1 authorization server that can: + +- Return 401 errors on MCP requests (to trigger OAuth flow initiation) +- Serve OAuth metadata endpoints (RFC 8414 discovery) +- Handle all three client identification modes (static, DCR, CIMD) +- Support authorization and token exchange flows +- Verify Bearer tokens on protected MCP endpoints + +**Decision**: Use **better-auth** (or similar third-party OAuth library) for the test server rather than implementing OAuth from scratch. This provides: + +- Faster implementation +- Production-like OAuth behavior +- Better security coverage +- Reduced maintenance burden + +### Integration with Existing Test Infrastructure + +The OAuth test server will integrate with the existing `composable-test-server.ts` infrastructure: + +1. **Extend `ServerConfig` Interface** (`core/test/composable-test-server.ts`): + + ```typescript + export interface ServerConfig { + // ... existing config ... + oauth?: { + /** + * Whether OAuth is enabled for this test server + */ + enabled: boolean; + + /** + * OAuth authorization server issuer URL + * Used for metadata endpoints and token issuance + */ + issuerUrl: URL; + + /** + * List of scopes supported by this authorization server + */ + scopesSupported?: string[]; + + /** + * If true, MCP endpoints require valid Bearer token + * Returns 401 Unauthorized if token is missing or invalid + */ + requireAuth?: boolean; + + /** + * Static/preregistered clients for testing + * These clients are pre-configured and don't require DCR + */ + staticClients?: Array<{ + clientId: string; + clientSecret?: string; + redirectUris?: string[]; + }>; + + /** + * Whether to support Dynamic Client Registration (DCR) + * If true, exposes /register endpoint for client registration + */ + supportDCR?: boolean; + + /** + * Whether to support CIMD (Client ID Metadata Documents) + * If true, server will fetch client metadata from clientMetadataUrl + */ + supportCIMD?: boolean; + + /** + * Token expiration time in seconds (default: 3600) + */ + tokenExpirationSeconds?: number; + + /** + * Whether to support refresh tokens (default: true) + */ + supportRefreshTokens?: boolean; + }; + } + ``` + +2. **Extend `TestServerHttp`** (`core/test/test-server-http.ts`): + - Install better-auth OAuth router on Express app (before MCP routes) + - Add Bearer token verification middleware on `/mcp` endpoint + - Return 401 if `requireAuth: true` and no valid token present + - Serve OAuth metadata endpoints: + - `/.well-known/oauth-authorization-server` (RFC 8414) + - `/.well-known/oauth-protected-resource` (RFC 8414) + - Handle client registration endpoint (`/register`) if DCR enabled + - Handle authorization endpoint (`/authorize`) - see "Authorization Endpoint" below + - Handle token endpoint (`/token`) + - Handle token revocation endpoint (`/revoke`) if supported + + **Authorization Endpoint Implementation**: + - better-auth provides the authorization endpoint (`/oauth/authorize` or similar) + - For automated testing, create a **test authorization page** that: + - Accepts authorization requests (client_id, redirect_uri, scope, state, code_challenge) + - Automatically approves the request (no user interaction required) + - Redirects to `redirect_uri` with authorization code and state + - This allows tests to programmatically complete the OAuth flow without browser automation + - For true E2E tests requiring user interaction, better-auth's built-in UI can be used + +3. **Create OAuth Test Fixtures** (`core/test/test-server-fixtures.ts`): + + ```typescript + /** + * Creates a test server configuration with OAuth enabled + */ + export function createOAuthTestServerConfig(options: { + requireAuth?: boolean; + scopesSupported?: string[]; + staticClients?: Array<{ clientId: string; clientSecret?: string }>; + supportDCR?: boolean; + supportCIMD?: boolean; + }): ServerConfig; + + /** + * Creates OAuth configuration for InspectorClient tests + */ + export function createOAuthClientConfig(options: { + mode: "static" | "dcr" | "cimd"; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + redirectUrl: string; + }): InspectorClientOptions["oauth"]; + + /** + * Helper function to programmatically complete OAuth authorization + * Makes HTTP GET request to authorization URL and extracts authorization code + * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event + * @returns Authorization code extracted from redirect URL + */ + export async function completeOAuthAuthorization( + authorizationUrl: URL, + ): Promise; + ``` + +### Authorization Endpoint and Test Flow + +**Authorization Endpoint**: +The test server will provide a functioning OAuth authorization endpoint (via better-auth) that: + +1. **Accepts Authorization Requests**: The endpoint receives authorization requests with: + - `client_id`: The OAuth client identifier + - `redirect_uri`: Where to redirect after approval + - `scope`: Requested OAuth scopes + - `state`: CSRF protection state parameter + - `code_challenge`: PKCE code challenge + - `response_type`: Always "code" for authorization code flow + +2. **Test Authorization Page**: For automated testing, the test server will provide a simple authorization page that: + - Automatically approves all authorization requests (no user interaction) + - Generates an authorization code + - Redirects to `redirect_uri` with the code and state parameter + - This allows tests to programmatically complete OAuth without browser automation + +3. **Programmatic Authorization Helper**: Tests can use a helper function to: + - Extract authorization URL from `oauthAuthorizationRequired` event + - Make HTTP GET request to authorization URL + - Parse redirect response to extract authorization code + - Call `client.completeOAuthFlow(authorizationCode)` to complete the flow + +**Example Test Flow**: + +```typescript +// 1. Configure test server with OAuth enabled +const server = new TestServerHttp({ + ...getDefaultServerConfig(), + oauth: { + enabled: true, + requireAuth: true, + staticClients: [{ clientId: "test-client", clientSecret: "test-secret" }], + }, +}); +await server.start(); + +// 2. Configure InspectorClient with OAuth +const client = new InspectorClient({ + serverUrl: server.url, + oauth: { + clientId: "test-client", + clientSecret: "test-secret", + redirectUrl: "http://localhost:3000/oauth/callback", + }, +}); + +// 3. Listen for OAuth authorization required event +let authUrl: URL | null = null; +client.addEventListener("oauthAuthorizationRequired", (event) => { + authUrl = event.detail.url; +}); + +// 4. Make MCP request (triggers 401, then OAuth flow) +try { + await client.listTools(); +} catch (error) { + // Expected: 401 error triggers OAuth flow +} + +// 5. Programmatically complete authorization +if (authUrl) { + // Make GET request to authorization URL (auto-approves in test server) + const response = await fetch(authUrl.toString(), { redirect: "manual" }); + const redirectUrl = response.headers.get("location"); + + // Extract authorization code from redirect URL + const redirectUrlObj = new URL(redirectUrl!); + const code = redirectUrlObj.searchParams.get("code"); + + // Complete OAuth flow + await client.completeOAuthFlow(code!); + + // 6. Retry original request (should succeed with token) + const tools = await client.listTools(); + expect(tools).toBeDefined(); +} +``` + +### Test Scenarios + +**Static Client Mode**: + +- Configure test server with `staticClients` +- Configure InspectorClient with matching `clientId`/`clientSecret` +- Test full OAuth flow without DCR +- Verify authorization endpoint auto-approves and redirects with code + +**DCR Mode**: + +- Configure test server with `supportDCR: true` +- Configure InspectorClient without `clientId` (triggers DCR) +- Test client registration, then full OAuth flow +- Verify DCR endpoint registers client, then authorization flow proceeds + +**CIMD Mode**: + +- Configure test server with `supportCIMD: true` +- Configure InspectorClient with `clientMetadataUrl` +- Test server fetches client metadata from URL +- Test full OAuth flow with URL-based client ID + +**401 Error Handling**: + +- Configure test server with `requireAuth: true` +- Make MCP request without token → expect 401 +- Verify `oauthAuthorizationRequired` event dispatched +- Programmatically complete OAuth flow (auto-approve authorization) +- Verify original request automatically retried with token + +**Token Verification**: + +- Configure test server with `requireAuth: true` +- Make MCP request with valid Bearer token → expect success +- Make MCP request with invalid/expired token → expect 401 + +### Implementation Steps + +1. **Install better-auth dependency** (or chosen OAuth library) + - Add to `core/package.json` as dev dependency + +2. **Create OAuth test server wrapper** (`core/test/oauth-test-server.ts`) + - Wrap better-auth configuration + - Integrate with Express app in `TestServerHttp` + - Handle static clients, DCR, CIMD modes + - Create test authorization page that auto-approves requests + - Provide helper function to programmatically extract authorization code from redirect + +3. **Extend `ServerConfig` interface** + - Add `oauth` configuration option + - Update `createMcpServer()` to handle OAuth config + +4. **Extend `TestServerHttp`** + - Install OAuth router before MCP routes + - Add Bearer token middleware + - Return 401 when `requireAuth: true` and token invalid + +5. **Create test fixtures** + - `createOAuthTestServerConfig()` + - `createOAuthClientConfig()` + - Helper functions for common OAuth test scenarios + +6. **Write integration tests** + - Test each client identification mode + - Test 401 error handling + - Test token verification + - Test full OAuth flow end-to-end + +## Storage Strategy + +### InspectorClient Storage (Node.js) - Zustand with File Persistence + +**Location**: `~/.mcp-inspector/oauth/state.json` (single Zustand store file) + +**Storage Format**: + +```json +{ + "state": { + "servers": { + "https://example.com/mcp": { + "tokens": { "access_token": "...", "refresh_token": "..." }, + "clientInformation": { "client_id": "...", ... }, + "preregisteredClientInformation": { "client_id": "...", ... }, + "codeVerifier": "...", + "scope": "...", + "serverMetadata": { ... } + } + } + }, + "version": 0 +} +``` + +**Benefits**: + +- Single file for all OAuth state across all servers +- Zustand handles serialization/deserialization automatically +- Atomic writes via Zustand's persist middleware +- Type-safe state management +- Easier to backup/restore (one file) + +**Security Considerations**: + +- File contains sensitive data (tokens, secrets) +- Use restrictive file permissions (600) for state.json +- Consider encryption for production use (future enhancement) + +### Web Client Storage (Browser) + +**Location**: Browser `sessionStorage` (unchanged - web client code not modified) + +**Key Format**: `[${serverUrl}] ${baseKey}` (unchanged) + +## Navigation Strategy + +### InspectorClient Navigation + +**Event-Driven Architecture**: InspectorClient dispatches `oauthAuthorizationRequired` events. Callers must register event listeners to handle these events. + +**UX Layer Options**: + +1. **Console Output**: Register event listener to print URL, wait for user to paste callback URL or authorization code +2. **Browser Open**: Register event listener to open URL in default browser (if available) +3. **Custom Navigation**: Register event listener to handle redirect in any custom way + +**Example Flow**: + +``` +1. InspectorClient detects 401 error +2. Initiates OAuth flow +3. Dispatches 'oauthAuthorizationRequired' event +4. If no listener registered, prints: "Please navigate to: https://auth.example.com/authorize?..." +5. UX layer listens for event and handles navigation (print, open browser, etc.) +6. Waits for user to provide authorization code or callback URL +7. User calls client.completeOAuthFlow(code) +8. Dispatches 'oauthComplete' event +9. Retries original request +``` + +## Error Handling + +### OAuth Flow Errors + +- **Discovery Errors**: Log and continue (fallback to server URL) +- **Registration Errors**: Log and throw (user must provide static client) +- **Authorization Errors**: Dispatch `oauthError` event, throw error +- **Token Exchange Errors**: Dispatch `oauthError` event, throw error + +### 401 Error Handling + +- **Transport / authProvider**: The SDK transport handles 401 when `authProvider` is used (token injection, auth, retry). InspectorClient does not detect 401 or retry connect/requests. +- **Caller flow**: Authenticate first (`authenticate()` or `authenticateGuided()`), complete OAuth, then `connect()`. If `connect()` is called without tokens, the transport may throw; callers retry `connect()` after OAuth. +- **Event-Based**: Dispatch events for UI to handle OAuth flow (`oauthAuthorizationRequired`, etc.) + +## Migration Notes + +### authProvider Migration (2025) + +InspectorClient now uses the SDK’s **`authProvider`** (`OAuthClientProvider`) for OAuth on HTTP transports (SSE, streamable-http) instead of a `getOAuthToken` callback and custom 401 handling. + +**Summary of changes**: + +- **Transport**: `createTransport` accepts `authProvider` (optional). For SSE and streamable-http with OAuth, we pass the provider; the SDK injects tokens and handles 401. `getOAuthToken` and OAuth-specific fetch wrapping have been removed. +- **InspectorClient**: All transport creation happens in `connect()` (single place for create, wrap, attach); for HTTP+OAuth the provider is created async there. We pass `authProvider` when creating the transport. On `disconnect()`, we null out the transport so the next `connect()` creates a fresh one. Removed: `is401Error`, `handleRequestWithOAuth`, connect-time 401 catch, and `pendingOAuthRequest`. +- **Caller flow**: **Authenticate first, then connect.** Call `authenticate()` or `authenticateGuided()`, have the user complete OAuth, call `completeOAuthFlow(code)`, then `connect()`. We no longer detect 401 on `connect()` or retry internally; the transport handles 401 when `authProvider` is used. +- **Guided mode**: Unchanged. Use `authenticateGuided()` → `completeOAuthFlow()` → `connect()`. The same provider (or shared storage) is used as `authProvider` when connecting after guided auth. +- **Custom headers**: Config `headers` / `requestInit` / `eventSourceInit` continue to be passed at transport creation and are merged with `authProvider` by the SDK. + +See **"Token Injection and authProvider"** above for details. + +### Web Client Migration (Future Consideration) + +**Current State**: Web client OAuth code remains unchanged and in place. + +**Future Migration Options** (not implemented now, but design should support): + +1. **Option A: Web Client Uses Shared Auth Code Directly** + - Web client imports from `core/auth/` + - Uses `BrowserOAuthClientProvider` from shared + - Uses Zustand store with sessionStorage adapter + - Minimal changes to web client code + +2. **Option B: Web Client Relies on InspectorClient for OAuth** + - Web client creates `InspectorClient` instance + - Uses InspectorClient's OAuth methods and events + - InspectorClient handles all OAuth logic + - Web client UI listens to InspectorClient events + +3. **Option C: Hybrid Approach** + - Some components use shared auth code directly (e.g., utilities, state machine) + - Other components use InspectorClient (e.g., OAuth flow initiation) + - Flexible migration path + +**Design Considerations**: + +- Shared auth code should be usable independently (not require InspectorClient) +- InspectorClient should be usable independently (not require web client) +- React hooks in `core/react/auth/hooks.ts` can be shared (pure logic, no rendering) +- React UI components cannot be shared (TUI uses Ink, web uses DOM) - each client implements its own + +### Breaking Changes + +- **None Expected**: All changes are additive (new shared code, new InspectorClient features) +- **Web Client**: Remains completely unchanged +- **API Compatibility**: InspectorClient API is additive only + +## Future Enhancements + +1. **Token Refresh**: Implemented via the SDK's `authProvider` when `refresh_token` is available; the provider persists and uses refresh tokens for automatic refresh after 401. No additional work required for standard flows. +2. **Encrypted Storage**: Encrypt sensitive OAuth data in Zustand store +3. **Multiple OAuth Providers**: Support multiple OAuth configurations per InspectorClient +4. **Web Client Migration**: Consider migrating web client to use shared auth code or InspectorClient + +## References + +- Web client OAuth implementation (unchanged): `client/src/lib/auth.ts`, `client/src/lib/oauth-state-machine.ts`, `client/src/utils/oauthUtils.ts` +- [MCP SDK OAuth APIs](https://github.com/modelcontextprotocol/typescript-sdk) - SDK OAuth client and server APIs +- [OAuth 2.1 Specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) - OAuth 2.1 protocol specification +- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - OAuth 2.0 Authorization Server Metadata +- [Zustand Documentation](https://github.com/pmndrs/zustand) - Zustand state management library +- [Zustand Persist Middleware](https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md) - Zustand persistence middleware +- [SEP-991: Client ID Metadata Documents](https://modelcontextprotocol.io/specification/security/oauth/#client-id-metadata-documents) - CIMD specification diff --git a/docs/protocol-and-state-managers-architecture.md b/docs/protocol-and-state-managers-architecture.md new file mode 100644 index 000000000..b7f992609 --- /dev/null +++ b/docs/protocol-and-state-managers-architecture.md @@ -0,0 +1,160 @@ +# Protocol and State Managers Architecture + +This document describes how the Inspector protocol and state managers work: **InspectorClient** is the protocol (connection, RPCs, notification dispatch). Optional **state managers** take the client, subscribe to its events, hold list or log state, and dispatch their own change events. **Hooks** subscribe to managers and expose state and methods to React. Apps create one InspectorClient and only the managers they need. + +--- + +## Nomenclature + +| Term | Meaning | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **InspectorClient** | Owns the MCP connection, transport, and SDK client. Exposes list RPCs (return data only, no storage), notification events (e.g. `toolsListChanged`, `taskStatusChange`), and all other request/stream methods. No cached lists or log arrays. | +| **State manager** | Optional object that holds state (lists or logs), takes an InspectorClient, subscribes to protocol events, and exposes getters, methods, and its own change events. | +| **Managed\*State** | Keeps a full list in sync with the server: subscribes to the relevant `*ListChanged` event and on connect/refresh fetches all pages, then dispatches a change event. | +| **Paged\*State** | Builds the list as the app loads pages: exposes `loadPage(cursor)`, merges results, dispatches change events; may subscribe to `*ListChanged` to refetch or invalidate. | +| **Log-style manager** | Holds an append-only list (messages, fetch requests, stderr). Protocol emits only per-entry events (`message`, `fetchRequest`, `stderrLog`). Manager appends and emits its own list-change event. | +| **Hook** | React hook that subscribes to a state manager’s events and exposes that manager’s state and methods. One hook per manager. | + +--- + +## InspectorClient + +**Responsibilities:** + +- **Connection and transport:** `connect()`, `disconnect()`, `getStatus()`. +- **List RPCs (stateless):** `listTools(cursor?, metadata?)`, `listResources(cursor?, metadata?)`, `listResourceTemplates(cursor?, metadata?)`, `listPrompts(cursor?, metadata?)`, `listRequestorTasks(cursor?)`. Each returns a promise with items and optional `nextCursor`. No internal cache. +- **Notification handling:** Registers for server notifications and dispatches signal events: `toolsListChanged`, `resourcesListChanged`, `resourceTemplatesListChanged`, `promptsListChanged`, `tasksListChanged`, `taskStatusChange` (with task payload), `requestorTaskUpdated` (client-origin task updates from getRequestorTask and callToolStream), `taskCancelled`. +- **Other RPC and streams:** `callTool`, `callToolStream`, `readResource`, `getPrompt`, `createMessage`, `elicit`, resource subscribe/unsubscribe, `setLoggingLevel`, etc. +- **Request handlers (server → client):** Roots, receiver tasks (createMessage/elicit with task params), etc. +- **OAuth and session:** Connection and auth; dispatches `saveSession` with sessionId (persistence is in FetchRequestLogState). +- **Log events (per-entry only):** Dispatches `message`, `fetchRequest`, `stderrLog` with payload. Does not maintain log lists or emit list-change events; log managers do that. + +**Still inside InspectorClient (not extracted to external managers):** roots (and `getRoots()`), pendingSamples, pendingElicitations, receiverTaskRecords, OAuth state machine, sessionId. These may be refactored into internal sub-managers later; see [inspector-client-sub-managers.md](inspector-client-sub-managers.md). + +--- + +## State Managers + +Managers take an **InspectorClient** in their constructor, subscribe to protocol events, and optionally call list RPCs. They expose getters (e.g. `getTools()`), their own change events (e.g. `toolsChange` with the full list), and methods (e.g. `loadPage(cursor)`, `refresh()`, `clear()`). + +**Protocol vs. manager events:** The protocol emits **signal** events (`toolsListChanged`, `taskStatusChange`, etc.). Managers subscribe to those and emit **state** events (`toolsChange`, `tasksChange`, etc.) with the current list as `event.detail`. UI and hooks subscribe to the manager’s events. + +**Type-safe events:** Protocol and managers use `TypedEventTarget`; each manager defines its own event map (e.g. `ManagedToolsStateEventMap`: `{ toolsChange: Tool[] }`). + +### List-style managers (Managed\*) + +| Manager | Protocol events | RPC | Behavior | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------ | +| **ManagedToolsState** | `toolsListChanged`, `statusChange` | `listTools` | On connect and `toolsListChanged`, fetches all pages; dispatches `toolsChange`. `refresh()`. | +| **ManagedResourcesState** | `resourcesListChanged`, `statusChange` | `listResources` | Same pattern. Optional `setMetadata()` for list_resources metadata. | +| **ManagedResourceTemplatesState** | `resourceTemplatesListChanged`, `statusChange` | `listResourceTemplates` | Same pattern. | +| **ManagedPromptsState** | `promptsListChanged`, `statusChange` | `listPrompts` | Same pattern. | +| **ManagedRequestorTasksState** | `connect`, `tasksListChanged`, `statusChange`, `taskStatusChange`, `requestorTaskUpdated`, `taskCancelled` | `listRequestorTasks` | Fetches all pages on connect/list changed; merges task updates; dispatches `tasksChange`. `refresh()`. | + +### Paged managers (Paged\*) + +| Manager | Protocol events | RPC | Behavior | +| ------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **PagedToolsState** | `statusChange` | `listTools(cursor)` | `loadPage(cursor)`, `clear()`. Clears on disconnect. | +| **PagedResourcesState** | `statusChange` | `listResources(cursor)` | `loadPage(cursor)`, `clear()`. Clears on disconnect. | +| **PagedResourceTemplatesState** | `statusChange` | `listResourceTemplates(cursor)` | Same. | +| **PagedPromptsState** | `statusChange` | `listPrompts(cursor)` | Same. | +| **PagedRequestorTasksState** | `statusChange`, `tasksListChanged`, `taskStatusChange`, `requestorTaskUpdated`, `taskCancelled` | `listRequestorTasks(cursor)` | `loadPage(cursor)`, `clear()`, `getNextCursor()`. Merges task updates; on `tasksListChanged` refetches first page. Clears on disconnect. | + +### Log-style managers + +| Manager | Protocol events | Manager emits | +| ------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **MessageLogState** | `message` | Appends; emits `messagesChange` (list). `getMessages()`, `clearMessages()`. Matches request/response; computes duration. | +| **FetchRequestLogState** | `fetchRequest` | Appends; emits `fetchRequestsChange` (list). Optional sessionStorage + sessionId: listens for `saveSession`, restores on creation. | +| **StderrLogState** | `stderrLog` | Appends; emits `stderrLogsChange` (list). | + +--- + +## Hooks + +Each state manager has a matching React hook. Hooks take `(InspectorClient | null, StateManager | null)` and return the manager’s state plus methods. + +| Manager | Hook | Returns (conceptually) | +| ----------------------------- | --------------------------- | --------------------------------------------------- | +| ManagedToolsState | useManagedTools | `{ tools, refresh }` | +| PagedToolsState | usePagedTools | `{ tools, loadPage(cursor), clear }` | +| ManagedResourcesState | useManagedResources | `{ resources, refresh }` | +| PagedResourcesState | usePagedResources | `{ resources, loadPage(cursor, metadata?), clear }` | +| ManagedResourceTemplatesState | useManagedResourceTemplates | `{ resourceTemplates, refresh }` | +| PagedResourceTemplatesState | usePagedResourceTemplates | `{ resourceTemplates, loadPage(cursor), clear }` | +| ManagedPromptsState | useManagedPrompts | `{ prompts, refresh }` | +| PagedPromptsState | usePagedPrompts | `{ prompts, loadPage(cursor), clear }` | +| ManagedRequestorTasksState | useManagedRequestorTasks | `{ tasks, refresh }` | +| PagedRequestorTasksState | usePagedRequestorTasks | `{ tasks, loadPage(cursor), clear, nextCursor }` | +| MessageLogState | useMessageLog | `{ messages }` | +| FetchRequestLogState | useFetchRequestLog | `{ fetchRequests }` | +| StderrLogState | useStderrLog | `{ stderrLogs }` | + +**useInspectorClient:** Takes the InspectorClient only. Returns `{ status, capabilities, serverInfo, instructions, appRendererClient, connect, disconnect }`. No list or log state. + +--- + +## App composition + +1. **Create client:** One `InspectorClient` instance (config, environment, OAuth as needed). +2. **Create only the state managers you need:** e.g. `new PagedToolsState(client)`, `new MessageLogState(client)`. Create them when the client is created or when switching clients; destroy them when the client is replaced. +3. **Use the matching hooks:** e.g. `usePagedTools(inspectorClient, pagedToolsState)`, `useMessageLog(messageLogState)`. + +**Web** uses InspectorClient plus Paged* managers (tools, resources, resource templates, prompts, requestor tasks) and log managers. **TUI** uses InspectorClient plus Managed* managers for lists and log managers. **CLI** uses InspectorClient plus Managed\* managers where it needs list output. + +--- + +## Code organization + +``` +core/ +ā”œā”€ā”€ mcp/ +│ ā”œā”€ā”€ inspectorClient.ts +│ ā”œā”€ā”€ inspectorClientEventTarget.ts +│ ā”œā”€ā”€ index.ts +│ ā”œā”€ā”€ ... # transport, config, types, etc. +│ └── state/ +│ ā”œā”€ā”€ index.ts +│ ā”œā”€ā”€ managedToolsState.ts +│ ā”œā”€ā”€ pagedToolsState.ts +│ ā”œā”€ā”€ managedResourcesState.ts +│ ā”œā”€ā”€ pagedResourcesState.ts +│ ā”œā”€ā”€ managedResourceTemplatesState.ts +│ ā”œā”€ā”€ pagedResourceTemplatesState.ts +│ ā”œā”€ā”€ managedPromptsState.ts +│ ā”œā”€ā”€ pagedPromptsState.ts +│ ā”œā”€ā”€ managedRequestorTasksState.ts +│ ā”œā”€ā”€ pagedRequestorTasksState.ts +│ ā”œā”€ā”€ messageLogState.ts +│ ā”œā”€ā”€ fetchRequestLogState.ts +│ └── stderrLogState.ts +ā”œā”€ā”€ react/ +│ ā”œā”€ā”€ useInspectorClient.ts +│ ā”œā”€ā”€ useManagedTools.ts +│ ā”œā”€ā”€ usePagedTools.ts +│ ā”œā”€ā”€ useManagedResources.ts +│ ā”œā”€ā”€ usePagedResources.ts +│ ā”œā”€ā”€ useManagedResourceTemplates.ts +│ ā”œā”€ā”€ usePagedResourceTemplates.ts +│ ā”œā”€ā”€ useManagedPrompts.ts +│ ā”œā”€ā”€ usePagedPrompts.ts +│ ā”œā”€ā”€ useManagedRequestorTasks.ts +│ ā”œā”€ā”€ usePagedRequestorTasks.ts +│ ā”œā”€ā”€ useMessageLog.ts +│ ā”œā”€ā”€ useFetchRequestLog.ts +│ └── useStderrLog.ts +ā”œā”€ā”€ auth/ +ā”œā”€ā”€ json/ +└── ... +``` + +Managers are framework-agnostic (EventTarget only). Hooks import the protocol and state types from core and subscribe to manager events. + +--- + +## Testing + +- **State managers:** Each manager has a test file in `core/__tests__/mcp/state/`. Tests use a **mocked InspectorClient** (stub list RPCs, dispatch protocol events). Assert manager state (e.g. `getTools()`) and events (e.g. `toolsChange`). No real transport. +- **Hooks:** Hook tests in `core/__tests__/react/` use mock state managers (EventTarget with getters and dispatch). Assert initial state, updates when manager dispatches, and method behavior (loadPage, clear, refresh). +- **InspectorClient:** Tests cover connection lifecycle, RPC delegation, notification dispatch, and integration with real or test servers where needed. diff --git a/docs/shared-code-architecture.md b/docs/shared-code-architecture.md new file mode 100644 index 000000000..65a2b6439 --- /dev/null +++ b/docs/shared-code-architecture.md @@ -0,0 +1,289 @@ +# Shared Code Architecture for MCP Inspector + +## Overview + +This document describes the **as-built** architecture for the MCP Inspector. The **CLI**, **TUI** (Terminal User Interface), and **web client** all use the **core** package and **InspectorClient**. This architecture prevents feature drift and reduces maintenance by providing a single source of truth for MCP client operations across all three interfaces. + +### Environment Isolation + +The architecture uses **environment isolation** to separate portable JavaScript from environment-specific code (Node.js vs. browser). `InspectorClient` is portable and accepts **injected dependencies** (seams) for environment-specific behavior: + +- **CLI/TUI (Node)**: Inject Node-specific implementations (`createTransportNode`, `NodeOAuthStorage`, file-based logging) +- **Web client (Browser)**: Inject browser-specific implementations (`createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`, `BrowserOAuthStorage` or `RemoteOAuthStorage`) + +`InspectorClient` remains unaware of which environment it's running in—it just uses the injected dependencies. This allows the same code to run in Node (CLI, TUI) and browser (web client). See [environment-isolation.md](environment-isolation.md) for detailed design. + +### Motivation + +Previously, the CLI and web client had no shared implementation, leading to: + +- **Feature drift**: Implementations diverged over time +- **Maintenance burden**: Bug fixes and features had to be implemented twice +- **Inconsistency**: Different behavior across interfaces +- **Duplication**: Similar logic implemented separately in each interface + +Adding the TUI (as-is) with yet another separate implementation seemed problematic given the above. + +The architecture addresses these issues by providing a single source of truth for MCP client operations in core. **On this branch, CLI, TUI, and web client all use core (InspectorClient and state managers).** + +## Architecture + +### Architecture Diagram + +![Shared Code Architecture](shared-code-architecture.svg) + +**Key concept**: Each environment (CLI, TUI, web client) injects environment-specific dependencies into `InspectorClient`. All three use the same `InspectorClient` and optional state managers from core: + +- **CLI/TUI**: Pass `environment` object with `transport: createTransportNode` (creates stdio, SSE, streamable-http transports directly in Node), `oauth.storage: NodeOAuthStorage` (file-based), `logger` (file-based pino logger) +- **Web client**: Pass `environment` object with `transport: createRemoteTransport` (creates `RemoteClientTransport` that talks to remote API server), `fetch: createRemoteFetch`, `logger: createRemoteLogger`, `oauth.storage: BrowserOAuthStorage` or `RemoteOAuthStorage` (sessionStorage or HTTP API) + +`InspectorClient` uses these injected dependencies to create transports and manage OAuth, remaining portable across all environments. + +### Protocol and state managers + +`InspectorClient` is the **protocol layer**: it owns the connection, exposes list RPCs (stateless), and dispatches events. **List and log state** (tools, resources, prompts, requestor tasks, messages, fetch requests, stderr) live in **optional state managers** in `core/mcp/state/`. Apps create an `InspectorClient` and only the state managers they need (e.g. `PagedToolsState`, `MessageLogState`); React hooks in `core/react/` subscribe to those managers. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md) for the full design. + +### Project Structure + +``` +inspector/ +ā”œā”€ā”€ cli/ # CLI workspace (uses core) +ā”œā”€ā”€ tui/ # TUI workspace (uses core) +ā”œā”€ā”€ web/ # Web client workspace (uses core) +ā”œā”€ā”€ core/ # Shared workspace package (InspectorClient, state managers, react, auth) +│ ā”œā”€ā”€ mcp/ # InspectorClient (protocol) + state managers +│ │ └── state/ # Optional state managers (tools, resources, logs, tasks) +│ ā”œā”€ā”€ react/ # useInspectorClient + hooks per state manager +│ ā”œā”€ā”€ json/ # JSON utilities +│ └── auth/ # OAuth infrastructure +└── package.json # Root workspace config +``` + +### Shared Package (`@modelcontextprotocol/inspector-core`) + +The `core/` directory is a **workspace package** that: + +- **Private** (`"private": true`) - internal-only, not published +- **Built separately** - compiles to `core/build/` with TypeScript declarations +- **Referenced via package name** - workspaces import using `@modelcontextprotocol/inspector-core/*` +- **Uses TypeScript Project References** - CLI, TUI, and web reference core for build ordering and type resolution +- **React peer dependency** - declares React 19.2.3 as peer dependency (consumers provide React) + +**Build Order**: Core must be built before CLI, TUI, and web (enforced via TypeScript Project References and CI workflows). + +## InspectorClient: The Core Shared Component + +### Overview + +`InspectorClient` (`core/mcp/inspectorClient.ts`) is the **protocol layer** around the MCP SDK `Client`: it owns the connection and transport, exposes list RPCs (stateless), and dispatches events. **List and log state** (tools, resources, prompts, tasks, messages, fetch requests, stderr) are held by **optional state managers** (`core/mcp/state/`); see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). InspectorClient provides: + +- **Unified Client Interface**: Single class for connection and all MCP operations +- **Client and Transport Lifecycle**: Creates and manages MCP SDK `Client` and `Transport`; `connect()` / `disconnect()` +- **Event-Driven Protocol**: Uses `EventTarget`; dispatches signal events (`*ListChanged`, `taskStatusChange`, `message`, `fetchRequest`, `stderrLog`, etc.) so state managers can subscribe +- **List RPCs (stateless)**: `listTools`, `listResources`, `listPrompts`, `listRequestorTasks`, etc.—return data only; no internal cache +- **Server Metadata**: Holds capabilities, serverInfo, instructions; dispatches change events +- **Transport Abstraction**: Works with all `Transport` types (stdio, SSE, streamable-http) +- **High-Level Methods**: Wrappers for tools/call, readResource, getPrompt, createMessage, elicit, and other MCP methods + +### Key Features + +**Connection Management:** + +- `connect()` - Establishes connection; registers notification handlers and fetches server metadata +- `disconnect()` - Closes connection and clears references +- Connection status tracking (`disconnected`, `connecting`, `connected`, `error`) + +**Protocol events (list and log data):** + +- InspectorClient dispatches **per-entry** and **signal** events (e.g. `message`, `fetchRequest`, `stderrLog`, `toolsListChanged`, `taskStatusChange`). **State managers** subscribe to these, hold lists (messages, tools, resources, tasks, etc.), and dispatch their own change events; React hooks subscribe to the managers. List and log state are not stored on InspectorClient. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). + +**Request and stderr events:** + +- Dispatches `fetchRequest` (per entry) and `stderrLog` (per entry) so log state managers (`FetchRequestLogState`, `StderrLogState`) can append and hold the lists. InspectorClient does not store message, fetch, or stderr lists. + +**MCP Method Wrappers:** + +- `listTools(metadata?)` - List available tools +- `callTool(name, args, generalMetadata?, toolSpecificMetadata?)` - Call a tool with automatic parameter conversion +- `listResources(metadata?)` - List available resources +- `readResource(uri, metadata?)` - Read a resource by URI +- `listResourceTemplates(metadata?)` - List resource templates +- `listPrompts(metadata?)` - List available prompts +- `getPrompt(name, args?, metadata?)` - Get a prompt with automatic argument stringification +- `getCompletions(resourceUri, prompt, metadata?)` - Get completions for resource templates or prompts +- `getRoots()` - List roots +- `setRoots(roots)` - Set roots +- `setLoggingLevel(level)` - Set logging level with capability checks + +**Advanced Features:** + +- **OAuth 2.1** - Full OAuth support (static client, DCR, CIMD, guided auth) with injectable storage, navigation, and redirect URL providers +- **Sampling** - Handles sampling requests, tracks pending samples, dispatches `newPendingSample` events +- **Elicitation** - Handles elicitation requests (form and URL), tracks pending elicitations, dispatches `newPendingElicitation` events +- **Roots** - Manages roots capability, handles `roots/list` requests, dispatches `rootsChange` events +- **Progress Notifications** - Handles progress notifications, dispatches `progressNotification` events, resets request timeout on progress +- **ListChanged Notifications** - Automatically reloads tools/resources/prompts when `listChanged` notifications are received + +**Configurable Options:** + +- `initialLoggingLevel` - Sets the logging level on connect if server supports logging (optional) +- `pipeStderr` - Whether to pipe stderr for stdio transports (default: `false`; TUI and CLI set this explicitly) +- `sample` - Whether to advertise sampling capability (default: `true`) +- Elicitation and other capability options as defined in `InspectorClientOptions` + +List and log size limits (`maxMessages`, `maxStderrLogEvents`, `maxFetchRequests`) are **not** properties of InspectorClient. Apps that use state managers (`MessageLogState`, `StderrLogState`, `FetchRequestLogState`) pass these as options when constructing those managers (e.g. `new MessageLogState(client, { maxMessages: 1000 })`). + +### Event System + +`InspectorClient` extends `EventTarget` for cross-platform compatibility. It dispatches **signal** and **per-entry** events; state managers subscribe and hold list state. Events include: + +- **Connection:** `statusChange`, `connect`, `disconnect` +- **Server metadata:** `capabilitiesChange`, `serverInfoChange`, `instructionsChange` +- **List signals:** `toolsListChanged`, `resourcesListChanged`, `promptsListChanged`, `tasksListChanged`, `taskStatusChange`, `requestorTaskUpdated`, `taskCancelled` +- **Log (per-entry):** `message`, `fetchRequest`, `stderrLog` +- **Other:** `error`, OAuth events, etc. + +State managers emit their own change events (e.g. `toolsChange`, `messagesChange`) with the current list. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). + +### Shared Module Structure + +The shared package is organized with environment-specific code separated into `node/` and `browser/` subdirectories. Portable code (no environment-specific APIs) lives in module roots; Node-specific code is under `node/` subdirectories; browser-specific code is under `browser/` subdirectories. + +**Main modules:** + +- **`core/mcp/`** - `InspectorClient` (protocol) and MCP types, transport, config +- **`core/mcp/state/`** - Optional state managers (Managed*/Paged* for tools, resources, prompts, tasks; MessageLogState, FetchRequestLogState, StderrLogState). See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). +- **`core/mcp/remote/`** - Remote transport infrastructure (portable client code) +- **`core/auth/`** - OAuth infrastructure (portable base code) +- **`core/react/`** - `useInspectorClient` and hooks per state manager (`usePagedTools`, `useMessageLog`, etc.) +- **`core/json/`** - JSON utilities +- **`core/storage/`** - Storage abstraction layer + +For detailed module organization, environment-specific modules, and package exports, see [environment-isolation.md](environment-isolation.md). + +## Current Usage + +### CLI Usage + +The CLI uses `InspectorClient` for all MCP operations: + +```typescript +// Convert CLI args to MCPServerConfig +const config = argsToMcpServerConfig(args); + +// Create InspectorClient +const inspectorClient = new InspectorClient(config, { + clientIdentity, + environment: { transport: createTransportNode, ... }, + initialLoggingLevel: "debug", +}); + +// Connect and use +await inspectorClient.connect(); +const result = await inspectorClient.listTools(args.metadata); +await inspectorClient.disconnect(); +``` + +### TUI Usage + +The TUI uses `InspectorClient` via the `useInspectorClient` React hook: + +```typescript +// In TUI component +const { status, messages, tools, resources, prompts, connect, disconnect } = + useInspectorClient(inspectorClient); + +// InspectorClient is created from config and managed by App.tsx +// The hook automatically subscribes to events and provides reactive state +``` + +**TUI Configuration:** + +- Uses state managers (e.g. `PagedToolsState`, `MessageLogState`) attached to InspectorClient; state managers subscribe to events and optionally auto-fetch lists on connect as needed +- Uses `useInspectorClient` and per–state-manager hooks (`usePagedTools`, `useMessageLog`, etc.) for reactive UI updates +- `ToolTestModal` uses `inspectorClient.callTool()` directly + +**TUI Status:** + +- **Experimental**: The TUI functionality may be considered "experimental" until sufficient testing and review of features and implementation. This allows for iteration and refinement based on user feedback before committing to a stable feature set. +- **Feature parity**: The TUI now supports OAuth (static client, CIMD, DCR, guided auth), completions, elicitation, sampling, and HTTP request tracking. InspectorClient provides all of these. + +**Entry Point:** +The TUI is invoked via the main `mcp-inspector` command with a `--tui` flag: + +- `mcp-inspector --tui ...` → TUI mode +- `mcp-inspector --cli ...` → CLI mode +- `mcp-inspector ...` → Web client mode (default) + +This provides a single entry point with consistent argument parsing across all three UX modes. + +### Web Client Usage + +The web client uses InspectorClient for all MCP operations: + +- **Environment**: `createWebEnvironment()` supplies `createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`, and OAuth storage/navigation. The browser talks to the same-origin API server (Hono `createRemoteApp`) for transport, fetch proxy, logging, and storage. +- **Lifecycle**: InspectorClient is created lazily via `ensureInspectorClient()` when the user connects or performs OAuth. The app attaches the same state managers (e.g. PagedToolsState, MessageLogState) and uses `useInspectorClient`, `usePagedTools`, `useMessageLog`, etc. +- **Config**: Web UI config (transport type, URL, command/args for stdio, headers, OAuth) is converted to `MCPServerConfig` and `InspectorClientOptions` when creating the client. + +### Feature coverage + +InspectorClient supports OAuth (static client, CIMD, DCR, guided auth), completions (`getCompletions`), elicitation, sampling, roots, progress notifications, and custom headers via `MCPServerConfig`. For which features are implemented in the TUI vs. web client, see [tui-web-client-feature-gaps.md](tui-web-client-feature-gaps.md). + +## Web App Integration + +### Current State + +The web client uses **InspectorClient** and the same state managers and hooks as the TUI. Core functionality: + +| Capability | Web client implementation | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Connection management | InspectorClient `connect()`, `disconnect()`, status events | +| Tools, resources, prompts | State managers (PagedToolsState, etc.) + hooks; InspectorClient methods | +| Message tracking | MessageLogState + useMessageLog; MessageEntry[] | +| OAuth | environment.oauth (BrowserOAuthStorage or RemoteOAuthStorage), environment.fetch (createRemoteFetch) | +| Custom headers | headers in MCPServerConfig (SSE/streamable-http) | +| Elicitation, sampling, roots, progress | InspectorClient events and methods; state managers as needed | +| Request history | FetchRequestLogState + useFetchRequestLog | +| Transport | createRemoteTransport (talks to Hono API server) | + +### Environment Isolation: What's Done vs. Pending + +Per [environment-isolation.md](environment-isolation.md): + +**Implemented:** All environment-specific dependencies are consolidated into `InspectorClientEnvironment` (transport, fetch, logger, oauth.storage, oauth.navigation, oauth.redirectUrlProvider). Transport is required; fetch, logger, and OAuth components are optional. Node uses `createTransportNode`; browser uses `createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`. Core runs in Node (CLI, TUI) and browser (web client). + +**Implemented (remote infrastructure):** + +- **Hono API server** — In `core/mcp/remote/node/`. Endpoints for transport (`/api/mcp/connect`, `send`, `events`, `disconnect`), proxy fetch (`/api/fetch`), logging (`/api/log`), and storage (`/api/storage/:storeId`). +- **createRemoteTransport + RemoteClientTransport** — In `core/mcp/remote/` (portable). Browser transport that talks to the remote server. +- **createRemoteFetch** — In `core/mcp/remote/`. Fetch that POSTs to `/api/fetch` for OAuth (CORS bypass). +- **createRemoteLogger** — In `core/mcp/remote/`. Pino logger that POSTs to `/api/log` via `pino/browser` transmit. +- **Storage abstraction** — `FileStorageAdapter` (Node), `RemoteStorageAdapter` (browser), `RemoteOAuthStorage` (HTTP API). All OAuth storage implementations use Zustand persist middleware for consistency. +- **Generic storage API** — `GET/POST/DELETE /api/storage/:storeId` endpoints for shared on-disk state between web app and TUI/CLI. See [environment-isolation.md](environment-isolation.md). +- **Node code organization** — `core/auth/node/`, `core/mcp/node/`, `core/mcp/remote/node/`. + +**Summary:** The web client uses InspectorClient with `createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`, and OAuth storage adapters. The Hono API server (`createRemoteApp`) is integrated into the web app server (see `web/src/server.ts`). The web app creates InspectorClient lazily (`ensureInspectorClient`), attaches state managers, and uses the same React hooks (`useInspectorClient`, `usePagedTools`, `useMessageLog`, etc.) as the TUI. + +## Web Client Implementation Notes + +### Architecture (as-built) + +The web client uses InspectorClient and state managers. Key pieces: + +- **Environment**: `createWebEnvironment()` (in `web/src/lib/adapters/environmentFactory.ts`) builds `InspectorClientEnvironment` with `createRemoteTransport`, `createRemoteFetch`, `createRemoteLogger`, and OAuth storage/navigation/redirect providers. The web server runs `createRemoteApp` (Hono) and serves `/api/mcp/*`, `/api/fetch`, `/api/log`, `/api/storage/*`. +- **InspectorClient lifecycle**: The web app creates InspectorClient lazily via `ensureInspectorClient()` when the user connects or performs auth. Config is converted to `MCPServerConfig`; the same state managers (PagedToolsState, MessageLogState, etc.) and hooks (`useInspectorClient`, `usePagedTools`, `useMessageLog`, etc.) as the TUI are used. +- **OAuth**: Injected via environment (`BrowserOAuthStorage` or `RemoteOAuthStorage`, `BrowserNavigation`, redirect URL provider). The web app implements the `oauth/callback` route and calls `inspectorClient.completeOAuthFlow()` or guided-auth APIs as needed. +- **State**: MessageEntry[], fetch request log, stderr log, tools/resources/prompts/tasks all come from state managers subscribed to InspectorClient events; no separate useConnection state. + +## Summary + +The architecture provides: + +- **Single source of truth** for MCP client operations via `InspectorClient` in core +- **CLI, TUI, and web client** all use core (InspectorClient and, where applicable, state managers and React hooks) +- **Consistent behavior** across all three interfaces +- **Reduced maintenance burden** — fix once, works everywhere +- **Type safety** through shared types +- **Event-driven updates** via EventTarget (cross-platform compatible) + +**As-built:** CLI, TUI, and web client use InspectorClient from core. TUI and web use state managers and the same React hooks; CLI calls InspectorClient methods directly. For TUI vs. web feature coverage, see [tui-web-client-feature-gaps.md](tui-web-client-feature-gaps.md). diff --git a/docs/shared-code-architecture.svg b/docs/shared-code-architecture.svg new file mode 100644 index 000000000..de80af8ac --- /dev/null +++ b/docs/shared-code-architecture.svg @@ -0,0 +1,93 @@ + + + + + + + + + + MCP Inspector Architecture + + + + CLI + Workspace + + + TUI + Workspace + React + Ink + + + Web Client + Workspace + React + + + + Inspector Core + @modelcontextprotocol/inspector-core + + + + React Hooks + useInspectorClient, usePagedTools, … + + + + State Managers + PagedToolsState, MessageLogState, … + + + + InspectorClient + Protocol layer, connection, events, MCP method wrappers + + + + MCP SDK + + Client + + Transports + + + + MCP Server + External + stdio/SSE/HTTP + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/tui-oauth-implementation-plan.md b/docs/tui-oauth-implementation-plan.md new file mode 100644 index 000000000..0914efb08 --- /dev/null +++ b/docs/tui-oauth-implementation-plan.md @@ -0,0 +1,184 @@ +# TUI OAuth Implementation Plan + +## Overview + +This document describes OAuth 2.1 support in the TUI for MCP servers that require OAuth (e.g. GitHub Copilot MCP). The implementation supports **DCR**, **CIMD**, and **static client** (clientId/clientSecret in config). + +**Goals:** + +- Enable TUI to connect to OAuth-protected MCP servers (SSE, streamable-http). +- Use a **localhost callback server** to receive the OAuth redirect (authorization code). +- Share callback-server logic between TUI and CLI where possible. +- Support both **Quick Auth** (automatic flow) and **Guided Auth** (step-by-step) with a **single redirect URL**. + +**Scope:** + +- **Quick Auth**: Automatic flow via `authenticate()`. Single redirect URI `http://localhost:/oauth/callback`. +- **Guided Auth**: Step-by-step flow via `beginGuidedAuth()`, `proceedOAuthStep()`, `runGuidedAuth()`. Same redirect URI; mode embedded in OAuth `state` parameter. + +--- + +## Implementation Status + +### Completed + +| Component | Status | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **Callback server** | Done. `core/auth/oauth-callback-server.ts`, single path `/oauth/callback`, serves both normal and guided flows. | +| **TUI integration** | Done. Auth available for all HTTP servers (SSE, streamable-http). Auth tab with Guided / Quick / Clear. | +| **Quick Auth** | Done. `authenticate()`, callback server, `openUrl`, `completeOAuthFlow`. | +| **Guided Auth** | Done. `beginGuidedAuth`, `proceedOAuthStep`, `runGuidedAuth`. Step progress UI, Space to advance, Enter to run to completion. | +| **Single redirect URL** | Done. Mode embedded in `state` (`normal:...` or `guided:...`). One `redirect_uri` registered with OAuth server. | +| **401 handling** | Done. On connect failure, if 401 seen in fetch history, show "401 Unauthorized. Press A to authenticate." | +| **DCR / CIMD** | Done. InspectorClient supports Dynamic Client Registration and CIMD. | + +### Static Client Auth + +**InspectorClient** supports static client configuration (`clientId`, `clientSecret` in oauth config). The **TUI does not yet support static client configuration**—there is no UI or config wiring for `clientId`/`clientSecret`. Adding this is pending work. + +### Pending Work + +1. **Callback state validation (optional)** + - Store the state we sent when building the auth URL. On callback, parse `state` via `parseOAuthState()` and verify the random part matches. + - Hardening step; current flow works without it since only one active flow runs at a time. + +2. **OAuth config in TUI** + - Add support for oauth options: `scope`, `storagePath`, `clientId`, `clientSecret`, `clientMetadataUrl`. + - Store in auth store for the server (or elsewhere)—**not** in server config. + - Wire through to InspectorClient when creating auth provider. + +3. **Redirect URI / port change** + - If the callback server restarts with a different port (e.g. between auth attempts), the OAuth server may have the old redirect_uri registered, causing "Unregistered redirect_uri". Workaround: clear OAuth state before retrying. Potential improvement: reuse port or document the limitation. + +4. **CLI OAuth** + - Wire the same callback server into the CLI for HTTP servers. Flow: start callback server, run `authenticate()`, open URL, receive callback, `completeOAuthFlow`, then connect. + +--- + +## Assumptions + +- **DCR, CIMD, or static client**: Auth options (clientId, clientSecret, clientMetadataUrl, etc.) live in auth store or similar—not in server config. +- **Discovery runs in Node**: TUI and CLI run in Node. OAuth metadata discovery uses `fetch` in Node—**no CORS** issues. +- **Single redirect URI**: Both normal and guided flows use `http://localhost:/oauth/callback`. Mode is embedded in the `state` parameter. + +--- + +## Single Redirect URL (Mode in State) + +We use **one redirect URL** for both normal and guided flows. The **mode** is embedded in the OAuth `state` parameter, which the authorization server echoes back unchanged. + +### State Format + +``` +{mode}:{random} +``` + +- `normal:a1b2c3...` (64 hex chars after colon) +- `guided:a1b2c3...` + +The random part is 32 bytes (64 hex chars) for CSRF protection. Legacy state (plain 64-char hex) is treated as `"normal"`. + +### Implementation + +- `generateOAuthStateWithMode(mode)` and `parseOAuthState(state)` in `core/auth/utils.ts` +- `BaseOAuthClientProvider.state()` uses mode-embedded state +- `redirect_uris` returns a single URL for both modes +- Callback server serves `/oauth/callback` only + +--- + +## Callback Server + +### Location + +- `core/auth/oauth-callback-server.ts` +- Exported from `@modelcontextprotocol/inspector-core/auth` + +### API + +```ts +type OAuthCallbackHandler = (params: { code: string; state?: string }) => Promise; +type OAuthErrorHandler = (params: { error: string; error_description?: string }) => void; + +start(options: { + port?: number; + onCallback?: OAuthCallbackHandler; + onError?: OAuthErrorHandler; +}): Promise<{ port: number; redirectUrl: string }>; + +stop(): Promise; +``` + +### Behavior + +1. Listens on configurable port (default `0` → OS-assigned). +2. Serves `GET /oauth/callback` only (both normal and guided). +3. On success: invokes `onCallback` with `{ code, state }`, responds with "OAuth complete. You can close this window.", then stops. +4. On error: invokes `onError`, responds with error HTML. +5. Caller must **not** `await callbackServer.stop()` inside `onCallback`; the server stops itself after sending the response (avoids deadlock). + +--- + +## TUI Flow + +### Config + +- Auth is available for all HTTP servers (SSE, streamable-http). +- **Auth config is not stored in server config.** OAuth options (scope, storagePath, clientId, clientSecret, clientMetadataUrl) will live in the auth store for the server or elsewhere—not in the MCP server config. +- `redirectUrl` is set from the callback server when the user starts auth. + +### Auth Tab + +- **Guided Auth**: Step-by-step. Space to advance one step, Enter to run to completion. +- **Quick Auth**: Automatic flow. +- **Clear OAuth State**: Clears tokens and state. +- Accelerators: G (Guided), Q (Quick), S (Clear) switch to Auth tab and select the corresponding action. + +### End-to-End Flow (Quick Auth) + +1. User selects HTTP server, presses Q or selects Quick Auth and Enter. +2. TUI starts callback server, sets `redirectUrl` on provider. +3. Calls `authenticate()`. +4. On `oauthAuthorizationRequired`, opens auth URL in browser. +5. User signs in; IdP redirects to `http://localhost:/oauth/callback?code=...&state=...`. +6. Callback server receives request, calls `completeOAuthFlow(code)`, responds with success page. +7. TUI shows "OAuth complete. Press C to connect." + +### End-to-End Flow (Guided Auth) + +1. User selects HTTP server, presses G or selects Guided Auth. +2. TUI starts callback server, sets `redirectUrl`, calls `beginGuidedAuth()`. +3. User advances with Space (or runs to completion with Enter). +4. At authorization step, browser opens with auth URL (state includes `guided:...`). +5. User signs in; IdP redirects to same `/oauth/callback` with code and state. +6. Callback server receives, calls `completeOAuthFlow(code)`, responds with success page. +7. TUI shows completion. + +--- + +## Config Shape + +**MCP server config:** + +```json +{ + "mcpServers": { + "hosted-everything": { + "type": "streamable-http", + "url": "https://example-server.modelcontextprotocol.io/mcp" + } + } +} +``` + +- Auth is available for all HTTP servers. Server config stays clean—**no oauth block**. +- Auth options (scope, storagePath, clientId, clientSecret, clientMetadataUrl) are **not** stored in server config. They will live in the auth store for the server or elsewhere. TUI does not yet support configuring these; defaults only. + +--- + +## References + +- [OAuth Support in InspectorClient](./oauth-inspectorclient-design.md) +- [TUI and Web Client Feature Gaps](./tui-web-client-feature-gaps.md) +- `core/auth/`: providers, state-machine, utils, storage-node, oauth-callback-server +- `core/mcp/inspectorClient.ts`: `authenticate`, `beginGuidedAuth`, `runGuidedAuth`, `proceedOAuthStep`, `completeOAuthFlow`, `authProvider` diff --git a/docs/tui-web-client-feature-gaps.md b/docs/tui-web-client-feature-gaps.md new file mode 100644 index 000000000..9747657b6 --- /dev/null +++ b/docs/tui-web-client-feature-gaps.md @@ -0,0 +1,756 @@ +# TUI and Web Client Feature Gap Analysis + +## Overview + +This document details the feature gaps between the TUI (Terminal User Interface) and the web client. The goal is to identify all missing features in the TUI and create a plan to close these gaps by extending `InspectorClient` and implementing the features in the TUI. + +**Code locations:** On the main/v1 tree the web app lives in `client/` (and sometimes `server/`). On this branch the web app lives in `web/`. Code references in this document use `web/` for the current app unless explicitly referring to the v1/main tree. + +## Feature Comparison + +**InspectorClient** is the shared client library that provides the core MCP functionality. Both the TUI and web client use `InspectorClient` under the hood. The gaps documented here are primarily **UI-level gaps** — features that `InspectorClient` supports but are not yet exposed in the TUI interface. + +For the current feature matrix (InspectorClient, Web v1, Web v1.5, TUI), see [MCP Feature Implementation Across Projects](mcp-feature-tracker.md). + +## Detailed Feature Gaps + +### 1. Resource Subscriptions + +**Web Client Support:** + +- Subscribes to resources via `resources/subscribe` +- Unsubscribes via `resources/unsubscribe` +- Tracks subscribed resources in state +- UI shows subscription status and subscribe/unsubscribe buttons +- Handles `notifications/resources/updated` notifications for subscribed resources + +**TUI Status:** + +- āŒ No support for resource subscriptions +- āŒ No subscription state management +- āŒ No UI for subscribe/unsubscribe actions + +**InspectorClient Status:** + +- āœ… `subscribeToResource(uri)` method - **COMPLETED** +- āœ… `unsubscribeFromResource(uri)` method - **COMPLETED** +- āœ… Subscription state tracking - **COMPLETED** (`getSubscribedResources()`, `isSubscribedToResource()`) +- āœ… Handler for `notifications/resources/updated` - **COMPLETED** +- āœ… `resourceSubscriptionsChange` event - **COMPLETED** +- āœ… `resourceUpdated` event - **COMPLETED** +- āœ… Cache clearing on resource updates - **COMPLETED** (clears both regular resources and resource templates with matching expandedUri) + +**TUI Status:** + +- āŒ No UI for resource subscriptions +- āŒ No subscription state management in UI +- āŒ No UI for subscribe/unsubscribe actions +- āŒ No handling of resource update notifications in UI + +**Implementation Requirements:** + +- āœ… Add `subscribeToResource(uri)` and `unsubscribeFromResource(uri)` methods to `InspectorClient` - **COMPLETED** +- āœ… Add subscription state tracking in `InspectorClient` - **COMPLETED** +- āŒ Add UI in TUI `ResourcesTab` for subscribe/unsubscribe actions +- āœ… Handle resource update notifications for subscribed resources - **COMPLETED** (in InspectorClient) + +**Code References:** + +- Web client: `web/src/App.tsx` (lines 781-809) +- Web client: `web/src/components/ResourcesTab.tsx` (lines 207-221) + +### 2. OAuth 2.1 Authentication + +**InspectorClient Support:** + +- OAuth 2.1 support in shared package (`core/auth/`), integrated via `authProvider` on HTTP transports (SSE, streamable-http) +- **Static/Preregistered Clients**: āœ… Supported +- **DCR (Dynamic Client Registration)**: āœ… Supported +- **CIMD (Client ID Metadata Documents)**: āœ… Supported via `clientMetadataUrl` in OAuth config +- Authorization code flow with PKCE, token exchange, token refresh (via SDK `authProvider` when `refresh_token` available) +- Guided mode (`authenticateGuided()`, `proceedOAuthStep()`, `getOAuthStep()`) and normal mode (`authenticate()`, `completeOAuthFlow()`) +- Configurable storage path (`oauth.storagePath`), default `~/.mcp-inspector/oauth/state.json` +- Events: `oauthAuthorizationRequired`, `oauthComplete`, `oauthError`, `oauthStepChange` + +**Web Client Support:** + +- Full browser-based OAuth 2.1 flow (uses its own OAuth code in `web/src/lib/`, unchanged): + - **Static/Preregistered Clients**: āœ… Supported - User provides client ID and secret via UI + - **DCR (Dynamic Client Registration)**: āœ… Supported - Falls back to DCR if no static client available + - **CIMD (Client ID Metadata Documents)**: āŒ Not supported - Web client does not set `clientMetadataUrl` + - Authorization code flow with PKCE, token exchange, token refresh +- OAuth state management via `InspectorOAuthClientProvider` +- Session storage for OAuth tokens, OAuth callback handling, automatic token injection into request headers + +**TUI Status:** + +- āœ… OAuth 2.1 support including static client, CIMD, DCR, and guided auth +- āœ… OAuth token management via Auth tab +- āœ… Browser-based OAuth flow with localhost callback server +- āœ… CLI options: `--client-id`, `--client-secret`, `--client-metadata-url`, `--callback-url` + +**Code References:** + +- InspectorClient OAuth: `core/mcp/inspectorClient.ts` (OAuth options, `authenticate`, `authenticateGuided`, `completeOAuthFlow`, events), `core/auth/` +- Web client: `web/src/lib/hooks/useConnection.ts`, `web/src/lib/auth.ts`, `web/src/lib/oauth-state-machine.ts` +- Design: [OAuth Support in InspectorClient](./oauth-inspectorclient-design.md) + +**Note:** OAuth in TUI requires a browser-based flow with a localhost callback server, which is feasible but different from the web client's approach. + +### 3. Sampling Requests + +**InspectorClient Support:** + +- āœ… Declares `sampling: {}` capability in client initialization (via `sample` option, default: `true`) +- āœ… Sets up request handler for `sampling/createMessage` requests automatically +- āœ… Tracks pending sampling requests via `getPendingSamples()` +- āœ… Provides `SamplingCreateMessage` class with `respond()` and `reject()` methods +- āœ… Dispatches `newPendingSample` and `pendingSamplesChange` events +- āœ… Methods: `getPendingSamples()`, `removePendingSample(id)` + +**Web Client Support:** + +- UI tab (`SamplingTab`) displays pending sampling requests +- `SamplingRequest` component shows request details and approval UI +- Handles approve/reject actions via `SamplingCreateMessage.respond()`/`reject()` +- Listens to `newPendingSample` events to update UI + +**TUI Status:** + +- āŒ No UI for sampling requests +- āŒ No sampling request display or handling UI + +**Implementation Requirements:** + +- Add UI in TUI for displaying pending sampling requests +- Add UI for approve/reject actions (call `respond()` or `reject()` on `SamplingCreateMessage`) +- Listen to `newPendingSample` and `pendingSamplesChange` events +- Add sampling tab or integrate into existing tabs + +**Code References:** + +- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 85-87, 225-226, 401-417, 573-600) +- Web client: `web/src/components/SamplingTab.tsx` +- Web client: `web/src/components/SamplingRequest.tsx` +- Web client: `web/src/App.tsx` (lines 328-333, 637-652) + +### 4. Elicitation Requests + +**InspectorClient Support:** + +- āœ… Declares `elicitation: {}` capability in client initialization (via `elicit` option, default: `true`) +- āœ… Sets up request handler for `elicitation/create` requests automatically +- āœ… Tracks pending elicitation requests via `getPendingElicitations()` +- āœ… Provides `ElicitationCreateMessage` class with `respond()` and `remove()` methods +- āœ… Dispatches `newPendingElicitation` and `pendingElicitationsChange` events +- āœ… Methods: `getPendingElicitations()`, `removePendingElicitation(id)` +- āœ… Supports both form-based (user-input) and URL-based elicitation modes + +#### 4a. Form-Based Elicitation (User-Input) + +**InspectorClient Support:** + +- āœ… Handles `ElicitRequest` with `requestedSchema` (form-based mode) +- āœ… Extracts `taskId` from `related-task` metadata when present +- āœ… Test fixtures: `createCollectElicitationTool()` for testing form-based elicitation + +**Web Client Support:** + +- āœ… UI tab (`ElicitationTab`) displays pending form-based elicitation requests +- āœ… `ElicitationRequest` component: + - Shows request message and schema + - Generates dynamic form from JSON schema + - Validates form data against schema + - Handles accept/decline/cancel actions via `ElicitationCreateMessage.respond()` +- āœ… Listens to `newPendingElicitation` events to update UI + +**TUI Status:** + +- āŒ No UI for form-based elicitation requests +- āŒ No form generation from JSON schema +- āŒ No UI for accept/decline/cancel actions + +**Implementation Requirements:** + +- Add UI in TUI for displaying pending form-based elicitation requests +- Add form generation from JSON schema (similar to tool parameter forms) +- Add UI for accept/decline/cancel actions (call `respond()` on `ElicitationCreateMessage`) +- Listen to `newPendingElicitation` and `pendingElicitationsChange` events +- Add elicitation tab or integrate into existing tabs + +#### 4b. URL-Based Elicitation + +**InspectorClient Support:** + +- āœ… Handles `ElicitRequest` with `mode: "url"` and `url` parameter +- āœ… Extracts `taskId` from `related-task` metadata when present +- āœ… Test fixtures: `createCollectUrlElicitationTool()` for testing URL-based elicitation + +**Web Client Support:** + +- āŒ No UI for URL-based elicitation requests +- āŒ No handling for URL-based elicitation mode + +**TUI Status:** + +- āŒ No UI for URL-based elicitation requests +- āŒ No handling for URL-based elicitation mode + +**Implementation Requirements:** + +- Add UI in TUI for displaying pending URL-based elicitation requests +- Add UI to display URL and message to user +- Add UI for accept/decline/cancel actions (call `respond()` on `ElicitationCreateMessage`) +- Optionally: Open URL in browser or provide copy-to-clipboard functionality +- Listen to `newPendingElicitation` and `pendingElicitationsChange` events +- Add elicitation tab or integrate into existing tabs + +**Code References:** + +- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 90-92, 227-228, 420-433, 606-639) +- `ElicitationCreateMessage`: `core/mcp/elicitationCreateMessage.ts` +- Test fixtures: `core/test/test-server-fixtures.ts` (`createCollectElicitationTool`, `createCollectUrlElicitationTool`) +- Web client: `web/src/components/ElicitationTab.tsx` +- Web client: `web/src/components/ElicitationRequest.tsx` (form-based only) +- Web client: `web/src/App.tsx` (lines 334-356, 653-669) +- Web client: `web/src/utils/schemaUtils.ts` (schema resolution for form-based elicitation) + +### 5. Tasks (Long-Running Operations) + +**Status:** + +- āœ… **COMPLETED** - Fully implemented in InspectorClient +- āœ… Implemented in web client (as of recent release) +- āŒ Not yet implemented in TUI + +**Overview:** +Tasks (SEP-1686) were introduced in MCP version 2025-11-25 to support long-running operations through a "call-now, fetch-later" pattern. Tasks enable servers to return a taskId immediately and allow clients to poll for status and retrieve results later, avoiding connection timeouts. + +**InspectorClient Support:** + +- āœ… `callToolStream()` method - Calls tools with task support, returns streaming updates +- āœ… `getTask(taskId)` method - Retrieves task status by taskId +- āœ… `getTaskResult(taskId)` method - Retrieves task result once completed +- āœ… `cancelTask(taskId)` method - Cancels a running task +- āœ… `listTasks(cursor?)` method - Lists all active tasks with pagination support +- āœ… `getTrackedRequestorTasks()` method - Returns array of currently tracked requestor tasks +- āœ… Task state tracking - Maintains cache of active tasks with automatic updates +- āœ… Task lifecycle events - Dispatches `taskCreated`, `taskStatusChange`, `taskCompleted`, `taskFailed`, `taskCancelled`, `tasksChange` events +- āœ… Elicitation integration - Links elicitation requests to tasks via `related-task` metadata +- āœ… Sampling integration - Links sampling requests to tasks via `related-task` metadata +- āœ… Progress notifications - Links progress notifications to tasks via `related-task` metadata +- āœ… Capability detection - `getTaskCapabilities()` checks server task support +- āœ… `callTool()` validation - Throws error if attempting to call tool with `taskSupport: "required"` using `callTool()` +- āœ… Task cleanup - Clears task cache on disconnect + +**Web Client Support:** + +- UI displays active tasks with status indicators +- Task status updates in real-time via event listeners +- Task cancellation UI (cancel button for running tasks) +- Task result display when tasks complete +- Integration with tool calls - shows task creation from `callToolStream()` +- Links tasks to elicitation/sampling requests when task is `input_required` + +**TUI Status:** + +- āŒ No UI for displaying active tasks +- āŒ No task status display or monitoring +- āŒ No task cancellation UI +- āŒ No task result display +- āŒ No integration with tool calls (tasks created via `callToolStream()` are not visible) +- āŒ No indication when tool requires task support (`taskSupport: "required"`) +- āŒ No linking of tasks to elicitation/sampling requests in UI +- āŒ No task lifecycle event handling in UI + +**Implementation Requirements:** + +- āœ… InspectorClient task support - **COMPLETED** (see [Task Support Design](./task-support-design.md)) +- āŒ Add TUI UI for task management: + - Display list of active tasks with status (`working`, `input_required`, `completed`, `failed`, `cancelled`) + - Show task details (taskId, status, statusMessage, createdAt, lastUpdatedAt) + - Display task results when completed + - Cancel button for running tasks (call `cancelTask()`) + - Real-time status updates via `taskStatusChange` event listener + - Task lifecycle event handling (`taskCreated`, `taskCompleted`, `taskFailed`, `taskCancelled`) +- āŒ Integrate tasks with tool calls: + - Use `callToolStream()` for tools with `taskSupport: "required"` (instead of `callTool()`) + - Show task creation when tool call creates a task + - Link tool call results to tasks +- āŒ Integrate tasks with elicitation/sampling: + - Display which task is waiting for input when elicitation/sampling request has `taskId` + - Show task status as `input_required` while waiting for user response + - Link elicitation/sampling UI to associated task +- āŒ Add task capability detection: + - Check `getTaskCapabilities()` to determine if server supports tasks + - Only show task UI if server supports tasks +- āŒ Handle task-related errors: + - Show error when attempting to call `taskSupport: "required"` tool with `callTool()` + - Display task failure messages from `taskFailed` events + +### 6. Completions + +**InspectorClient Support:** + +- āœ… `getCompletions()` method sends `completion/complete` requests +- āœ… Supports resource template completions: `{ type: "ref/resource", uri: string }` +- āœ… Supports prompt argument completions: `{ type: "ref/prompt", name: string }` +- āœ… Handles `MethodNotFound` errors gracefully (returns empty array if server doesn't support completions) +- āœ… Completion requests include: + - `ref`: Resource template URI or prompt name + - `argument`: Field name and current (partial) value + - `context`: Optional context with other argument values +- āœ… Returns `{ values: string[] }` with completion suggestions + +**Web Client Support:** + +- Detects completion capability via `serverCapabilities.completions` +- `handleCompletion()` function calls `InspectorClient.getCompletions()` +- Used in resource template forms for autocomplete +- Used in prompt forms with parameters for autocomplete +- `useCompletionState` hook manages completion state and debouncing + +**TUI Status:** + +- āœ… Prompt fetching with parameters - **COMPLETED** (modal form for collecting prompt arguments) +- āŒ No completion support for resource template forms +- āŒ No completion support for prompt parameter forms +- āŒ No completion capability detection in UI +- āŒ No completion request handling in UI + +**Implementation Requirements:** + +- Add completion capability detection in TUI (via `InspectorClient.getCapabilities()?.completions`) +- Integrate `InspectorClient.getCompletions()` into TUI forms: + - **Resource template forms** (`ResourceTestModal`) - autocomplete for template variable values + - **Prompt parameter forms** (`PromptTestModal`) - autocomplete for prompt argument values +- Add completion state management (debouncing, loading states) +- Trigger completions on input change with debouncing + +**Code References:** + +- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 902-966) - `getCompletions()` method +- Web client: `web/src/lib/hooks/useConnection.ts` (lines 309, 384-386) +- Web client: `web/src/lib/hooks/useCompletionState.ts` +- Web client: `web/src/components/ResourcesTab.tsx` (lines 88-101) +- TUI: `tui/src/components/PromptTestModal.tsx` - Prompt form (needs completion integration) +- TUI: `tui/src/components/ResourceTestModal.tsx` - Resource template form (needs completion integration) + +### 6. Progress Tracking + +**Use Case:** + +Long-running operations (tool calls, resource reads, prompt invocations, etc.) can send progress notifications (`notifications/progress`) to keep clients informed of execution status. This is useful for: + +- Showing progress bars or status updates +- Resetting request timeouts on progress notifications +- Providing user feedback during long operations + +**Request timeouts and `resetTimeoutOnProgress`:** + +The MCP SDK applies a **per-request timeout** to all client requests (`listTools`, `callTool`, `getPrompt`, etc.). If no `timeout` is passed in `RequestOptions`, the SDK uses `DEFAULT_REQUEST_TIMEOUT_MSEC` (60 seconds). When the timeout is exceeded, the SDK raises an `McpError` with code `RequestTimeout` and the request fails—even if the server is still working and sending progress. + +**`resetTimeoutOnProgress`** is an SDK `RequestOptions` flag. When `true`, each `notifications/progress` received for that request **resets** the per-request timeout. That allows long-running operations that send periodic progress to run beyond 60 seconds without failing. (An optional `maxTotalTimeout` caps total wait time regardless of progress.) + +The SDK runs timeout reset **only** when a per-request `onprogress` callback exists; it also injects `progressToken: messageId` for routing. `InspectorClient` passes per-request `onprogress` when progress is enabled (so timeout reset **takes effect**) and collects the caller's `progressToken` from metadata. We **do not** expose that token to the server—the SDK overwrites it with `messageId`—we inject it only into dispatched `progressNotification` events so listeners can correlate progress with the request that triggered it. We pass `resetTimeoutOnProgress` (default: `true`) and optional `timeout` through; both are honored. Set `resetTimeoutOnProgress: false` for strict timeout caps or fail-fast behavior. + +**Web Client Support:** + +- **Progress Token**: Generates and includes `progressToken` in request metadata: + ```typescript + const mergedMetadata = { + ...metadata, + progressToken: progressTokenRef.current++, + ...toolMetadata, + }; + ``` +- **Progress Callback**: Sets up `onprogress` callback in `useConnection`: + ```typescript + if (mcpRequestOptions.resetTimeoutOnProgress) { + mcpRequestOptions.onprogress = (params: Progress) => { + if (onNotification) { + onNotification({ + method: "notifications/progress", + params, + }); + } + }; + } + ``` +- **Progress Display**: Progress notifications are displayed in the "Server Notifications" window +- **Timeout Reset**: `resetTimeoutOnProgress` option resets request timeout when progress notifications are received + +**InspectorClient Status:** + +- āœ… Progress - Per-request `onprogress` when `progress` enabled; dispatches `progressNotification` events (no global progress handler) +- āœ… Progress token - Accepts `progressToken` in metadata; we inject it into dispatched events only (not sent to server), so listeners can correlate +- āœ… Event-based - Clients listen for `progressNotification` events +- āœ… Timeout reset - `resetTimeoutOnProgress` (default: `true`), optional `timeout`; both honored via per-request `onprogress` + +**TUI Status:** + +- āŒ No progress tracking support +- āŒ No progress notification display +- āŒ No progress token management + +**Implementation Requirements:** + +- āœ… **Completed in InspectorClient:** + - Per-request `onprogress` when `progress: true`; dispatch `progressNotification` events from callback (no global progress handler) + - Caller's `progressToken` from metadata injected into events only (not sent to server); full params include `progress`, `total`, `message` + - `progressToken` in metadata supported (e.g. `callTool`, `getPrompt`, `readResource`, list methods) + - `resetTimeoutOnProgress` (default: `true`) and optional `timeout` passed as `RequestOptions`; timeout reset honored +- āŒ **TUI UI Support Needed:** + - Show progress notifications during long-running operations + - Display progress status in results view + - Optional: Progress bars or percentage indicators + +**Code References:** + +- InspectorClient: `core/mcp/inspectorClient.ts` - `getRequestOptions(progressToken?)` builds per-request `onprogress`, injects token into dispatched events +- InspectorClient: `core/mcp/inspectorClient.ts` - `callTool`, `callToolStream`, `getPrompt`, `readResource`, list methods pass `metadata?.progressToken` into `getRequestOptions` +- Web client: `web/src/App.tsx` (lines 840-892) - Progress token generation and tool call +- Web client: `web/src/lib/hooks/useConnection.ts` (lines 214-226) - Progress callback setup +- SDK: `@modelcontextprotocol/sdk` `shared/protocol` - `DEFAULT_REQUEST_TIMEOUT_MSEC` (60_000), `RequestOptions` (`timeout`, `resetTimeoutOnProgress`, `maxTotalTimeout`), `Progress` type + +### 7. ListChanged Notifications + +**Use Case:** + +MCP servers can send `listChanged` notifications when the list of tools, resources, or prompts changes. This allows clients to automatically refresh their UI when the server's capabilities change, without requiring manual refresh actions. + +**Web Client Support:** + +- **Capability Declaration**: Declares `roots: { listChanged: true }` in client capabilities +- **Notification Handlers**: Sets up handlers for: + - `notifications/tools/list_changed` + - `notifications/resources/list_changed` + - `notifications/prompts/list_changed` +- **Auto-refresh**: When a `listChanged` notification is received, the web client automatically calls the corresponding `list*()` method to refresh the UI +- **Notification Processing**: All notifications are passed to `onNotification` callback, which stores them in state for display + +**InspectorClient and state managers:** + +- āœ… InspectorClient registers notification handlers for `notifications/tools/list_changed`, `notifications/resources/list_changed`, `notifications/prompts/list_changed` and dispatches **signal** events (e.g. `toolsListChanged`, `resourcesListChanged`, `promptsListChanged`) - **COMPLETED** +- āœ… **State managers** (in `core/mcp/state/`) subscribe to those signals, call list RPCs, hold lists/caches, and dispatch **state** events (`toolsChange`, `resourcesChange`, `resourceTemplatesChange`, `promptsChange`) - **COMPLETED**. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). + +**TUI Status:** + +- āœ… `listChanged` notifications flow: InspectorClient dispatches signals; state managers (if used) reload lists and dispatch `toolsChange` etc. - **COMPLETED** +- āœ… TUI automatically reflects changes when it subscribes to state manager events (or to InspectorClient signals and fetches list data) - **COMPLETED** +- āŒ No UI indication when lists are auto-refreshed (optional, but useful for debugging) + +**Note on TUI Support:** + +List and cache state live in **optional state managers**, not on InspectorClient. The flow: + +1. **Server Capability**: The MCP server must advertise `listChanged` capability (e.g., `tools: { listChanged: true }`, `resources: { listChanged: true }`, `prompts: { listChanged: true }`). + +2. **InspectorClient**: When connected, it registers notification handlers and dispatches signal events (`toolsListChanged`, etc.). It does not store lists or dispatch `toolsChange`/`resourcesChange`/etc. + +3. **State managers**: Optional managers (e.g. ManagedToolsState, PagedToolsState) subscribe to those signals, call `listTools()`/etc., hold the list, and dispatch `toolsChange` (and similar) with the current list. Apps and hooks subscribe to the managers. + +4. **TUI**: If the TUI uses state managers (or equivalent), it listens to their events to refresh the UI. + +**Important**: The client does NOT need to advertise `listChanged` capability - it only needs to check if the server supports it. The handlers are registered automatically based on server capabilities. + +**Implementation Requirements:** + +- āœ… InspectorClient: notification handlers in `connect()` for `listChanged`, dispatch signal events - **COMPLETED** +- āœ… State managers: subscribe to signals, call list RPCs, hold lists, dispatch `toolsChange`/etc. - **COMPLETED** +- āœ… TUI can use state managers (or subscribe to signals and fetch) for list updates - **COMPLETED** +- āŒ Add UI in TUI to handle and display these notifications (optional, but useful for debugging) + +**Code References:** + +- Web client: `web/src/lib/hooks/useConnection.ts` (or equivalent) - capability declaration and notification handling +- InspectorClient: `core/mcp/inspectorClient.ts` (listChanged handlers in `connect()`) - signal dispatch +- State managers: `core/mcp/state/`, [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md) + +### 8. Roots Support + +**Use Case:** + +Roots are file system paths (as `file://` URIs) that define which directories an MCP server can access. This is a security feature that allows servers to operate within a sandboxed set of directories. Clients can: + +- List the current roots configured on the server +- Set/update the roots (if the server supports it) +- Receive notifications when roots change + +**Web Client Support:** + +- **Capability Declaration**: Declares `roots: { listChanged: true }` in client capabilities +- **UI Component**: `RootsTab` component allows users to: + - View current roots + - Add new roots (with URI and optional name) + - Remove roots + - Save changes (calls `listRoots` with updated roots) +- **Roots Management**: + - `getRoots` callback passed to `useConnection` hook + - Roots are stored in component state + - When roots are changed, `handleRootsChange` is called to send updated roots to server +- **Notification Support**: Handles `notifications/roots/list_changed` notifications (via fallback handler) + +**InspectorClient Support:** + +- āœ… `getRoots()` method - Returns current roots +- āœ… `setRoots(roots)` method - Updates roots and sends notification to server if supported +- āœ… Handler for `roots/list` requests from server (returns current roots) +- āœ… Notification handler for `notifications/roots/list_changed` from server +- āœ… `roots: { listChanged: true }` capability declaration (when `roots` option is provided) +- āœ… `rootsChange` event dispatched when roots are updated +- āœ… Roots configured via `roots` option in `InspectorClientOptions` (even empty array enables capability) + +**TUI Status:** + +- āŒ No roots management UI +- āŒ No roots configuration support + +**Implementation Requirements:** + +- āœ… `getRoots()` and `setRoots()` methods - **COMPLETED** in `InspectorClient` +- āœ… Handler for `roots/list` requests - **COMPLETED** in `InspectorClient` +- āœ… Notification handler for `notifications/roots/list_changed` - **COMPLETED** in `InspectorClient` +- āœ… `roots: { listChanged: true }` capability declaration - **COMPLETED** in `InspectorClient` +- āŒ Add UI in TUI for managing roots (similar to web client's `RootsTab`) + +**Code References:** + +- `InspectorClient`: `core/mcp/inspectorClient.ts` - `getRoots()`, `setRoots()`, roots/list handler, and notification support +- Web client: `web/src/components/RootsTab.tsx` - Roots management UI +- Web client: `web/src/lib/hooks/useConnection.ts` (lines 422-424, 357) - Capability declaration and `getRoots` callback +- Web client: `web/src/App.tsx` (lines 1225-1229) - RootsTab usage + +### 9. Custom Headers + +**Use Case:** + +Custom headers are used to send additional HTTP headers when connecting to MCP servers over HTTP-based transports (SSE or streamable-http). Common use cases include: + +- **Authentication**: API keys, bearer tokens, or custom authentication schemes + - Example: `Authorization: Bearer ` + - Example: `X-API-Key: ` +- **Multi-tenancy**: Tenant or organization identifiers + - Example: `X-Tenant-ID: acme-inc` +- **Environment identification**: Staging vs production + - Example: `X-Environment: staging` +- **Custom server requirements**: Any headers required by the MCP server + +**InspectorClient Support:** + +- āœ… `MCPServerConfig` supports `headers: Record` for SSE and streamable-http transports +- āœ… Headers are passed to the SDK transport during creation +- āœ… Headers are included in all HTTP requests to the MCP server +- āœ… Works with both SSE and streamable-http transports +- āŒ Not supported for stdio transport (stdio doesn't use HTTP) + +**Web Client Support:** + +- **UI Component**: `CustomHeaders` component in the Sidebar's authentication section +- **Features**: + - Add/remove headers with name/value pairs + - Enable/disable individual headers (toggle switch) + - Mask header values by default (password field with show/hide toggle) + - Form mode: Individual header inputs + - JSON mode: Edit all headers as a JSON object + - Validation: Only enabled headers with both name and value are sent +- **Integration**: + - Headers are stored in component state + - Passed to `useConnection` hook + - Converted to `Record` format for transport + - OAuth tokens can be automatically injected into `Authorization` header if no custom `Authorization` header exists + - Custom header names are tracked and sent to the proxy server via `x-custom-auth-headers` header + +**TUI Status:** + +- āŒ No header configuration UI +- āŒ No way for users to specify custom headers in TUI server config +- āœ… `InspectorClient` supports headers if provided in config (but TUI doesn't expose this) + +**Implementation Requirements:** + +- Add header configuration UI in TUI server configuration +- Allow users to add/edit/remove headers similar to web client +- Store headers in TUI server config +- Pass headers to `InspectorClient` via `MCPServerConfig.headers` +- Consider masking sensitive header values in the UI + +**Code References:** + +- Web client: `web/src/components/CustomHeaders.tsx` - Header management UI component +- Web client: `web/src/lib/hooks/useConnection.ts` (lines 453-514) - Header processing and transport creation +- `InspectorClient`: `core/mcp/config.ts` (lines 118-129) - Headers in `MCPServerConfig` +- `InspectorClient`: `core/mcp/transport.ts` (lines 100-134) - Headers passed to SDK transports + +## Implementation Priority + +### High Priority (Core MCP Features) + +1. **OAuth** - Required for many MCP servers, critical for production use +2. **Sampling** - Core MCP capability, enables LLM sampling workflows +3. **Elicitation** - Core MCP capability, enables interactive workflows +4. **Tasks** - Core MCP capability (v2025-11-25), enables long-running operations without timeouts - āœ… **COMPLETED** in InspectorClient + +### Medium Priority (Enhanced Features) + +5. **Resource Subscriptions** - Useful for real-time resource updates +6. **Completions** - Enhances UX for form filling +7. **Custom Headers** - Useful for custom authentication schemes +8. **ListChanged Notifications** - Auto-refresh lists when server data changes +9. **Roots Support** - Manage file system access for servers +10. **Progress Tracking** - User feedback during long-running operations +11. **Pagination Support** - Handle large lists efficiently (COMPLETED) + +## InspectorClient Extensions Needed + +Based on this analysis, `InspectorClient` needs the following additions: + +1. **Resource Methods** (some already exist): + - āœ… `readResource(uri, metadata?)` - Already exists + - āœ… `listResourceTemplates()` - Already exists + - āœ… Resource template `list` callback support - Already exists (via `listResources()`) + - āœ… `subscribeToResource(uri)` - **COMPLETED** + - āœ… `unsubscribeFromResource(uri)` - **COMPLETED** + - āœ… `getSubscribedResources()` - **COMPLETED** + - āœ… `isSubscribedToResource(uri)` - **COMPLETED** + - āœ… `supportsResourceSubscriptions()` - **COMPLETED** + - āœ… Resource / resource template / prompt / tool-call result access - **COMPLETED** (via InspectorClient RPCs; list and optional content caching are in state managers or app layer; see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)) + +2. **Sampling Support**: + - āœ… `getPendingSamples()` - Already exists + - āœ… `removePendingSample(id)` - Already exists + - āœ… `SamplingCreateMessage.respond(result)` - Already exists + - āœ… `SamplingCreateMessage.reject(error)` - Already exists + - āœ… Automatic request handler setup - Already exists + - āœ… `sampling: {}` capability declaration - Already exists (via `sample` option) + +3. **Elicitation Support**: + - āœ… `getPendingElicitations()` - Already exists + - āœ… `removePendingElicitation(id)` - Already exists + - āœ… `ElicitationCreateMessage.respond(result)` - Already exists + - āœ… Automatic request handler setup - Already exists + - āœ… `elicitation: {}` capability declaration - Already exists (via `elicit` option) + +4. **Completion Support**: + - āœ… `getCompletions(ref, argumentName, argumentValue, context?, metadata?)` - Already exists + - āœ… Supports resource template completions - Already exists + - āœ… Supports prompt argument completions - Already exists + - āŒ Integration into TUI `ResourceTestModal` for template variable completion + - āŒ Integration into TUI `PromptTestModal` for prompt argument completion + +5. **OAuth Support**: + - āœ… OAuth token management (shared auth, configurable storage) + - āœ… OAuth flow initiation (`authenticate()`, `authenticateGuided()`, `completeOAuthFlow()`) + - āœ… Token injection via `authProvider` on HTTP transports + - āœ… TUI integration and UI (OAuth 2.1, static client, CIMD, DCR, guided auth, browser-based flow, localhost callback server) + +6. **ListChanged Notifications**: + - āœ… Notification handlers for `notifications/tools/list_changed` - **COMPLETED** + - āœ… Notification handlers for `notifications/resources/list_changed` - **COMPLETED** + - āœ… Notification handlers for `notifications/prompts/list_changed` - **COMPLETED** + - āœ… Auto-refresh lists when notifications received - **COMPLETED** + - āœ… Configurable via `listChangedNotifications` option - **COMPLETED** + - āœ… Cache preservation and cleanup - **COMPLETED** + +7. **Roots Support**: + - āœ… `getRoots()` method - Already exists + - āœ… `setRoots(roots)` method - Already exists + - āœ… Handler for `roots/list` requests - Already exists + - āœ… Notification handler for `notifications/roots/list_changed` - Already exists + - āœ… `roots: { listChanged: true }` capability declaration - Already exists (when `roots` option provided) + - āŒ Integration into TUI for managing roots + +8. **Pagination Support**: + - āœ… Cursor parameter support in `listResources()` - **COMPLETED** + - āœ… Cursor parameter support in `listResourceTemplates()` - **COMPLETED** + - āœ… Cursor parameter support in `listPrompts()` - **COMPLETED** + - āœ… Cursor parameter support in `listTools()` - **COMPLETED** + - āœ… Return `nextCursor` from list methods - **COMPLETED** + - āœ… Pagination helper methods (`listAll*()`) - **COMPLETED** + +9. **Progress Tracking**: + - āœ… Progress notification handling - Implemented (dispatches `progressNotification` events) + - āœ… Progress token support - Implemented (accepts `progressToken` in metadata) + - āœ… Event-based API - Clients listen for `progressNotification` events (no callbacks needed) + - āœ… Timeout reset on progress - Per-request `onprogress` when progress enabled; `resetTimeoutOnProgress` and `timeout` honored + +## Notes + +- **HTTP Request Tracking**: InspectorClient dispatches `fetchRequest` events (per entry); **FetchRequestLogState** (in `core/mcp/state/`) holds the list and emits `fetchRequestsChange`. TUI displays these in a `RequestsTab`. Web client can use the same state manager. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). +- **Resource Subscriptions**: Web client supports this, but TUI does not. InspectorClient supports resource subscriptions with `subscribeToResource()`, `unsubscribeFromResource()`, and handling of `notifications/resources/updated`. +- **OAuth**: Web client has full OAuth support. TUI now supports OAuth 2.1 including static client, CIMD, DCR, and guided auth, with browser-based flow and localhost callback server. +- **Completions**: InspectorClient has full completion support via `getCompletions()`. Web client uses this for resource template forms and prompt parameter forms. TUI has both resource template forms and prompt parameter forms, but completion support is still needed to provide autocomplete suggestions. +- **Sampling**: InspectorClient has full sampling support. Web client UI displays and handles sampling requests. TUI needs UI to display and handle sampling requests. +- **Elicitation**: InspectorClient has full elicitation support. Web client UI displays and handles elicitation requests. TUI needs UI to display and handle elicitation requests. +- **ListChanged Notifications**: List and cache state live in **state managers** (see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)). InspectorClient dispatches signal events (`toolsListChanged`, etc.); state managers subscribe, call list RPCs, hold lists, and dispatch `toolsChange`/etc. Web client and TUI use state managers (or equivalent) to refresh lists when notifications are received. +- **Roots**: InspectorClient has full roots support with `getRoots()` and `setRoots()` methods, handler for `roots/list` requests, and notification support. Web client has a `RootsTab` UI for managing roots. TUI does not yet have UI for managing roots. +- **Pagination**: InspectorClient exposes list RPCs with cursor params and optional `nextCursor`. **State managers** (Paged* and Managed*) handle pagination and list state. Web client uses these; TUI can use the same managers. +- **Progress Tracking**: Web client supports progress tracking by generating `progressToken`, using `onprogress` callbacks, and displaying progress notifications. InspectorClient passes per-request `onprogress` when progress is enabled (so timeout reset is honored), collects `progressToken` from metadata, injects it only into dispatched `progressNotification` events (not sent to server), and passes `resetTimeoutOnProgress`/`timeout` through. TUI does not yet have UI support for displaying progress notifications. +- **Tasks**: Tasks (SEP-1686) were introduced in MCP version 2025-11-25 to support long-running operations. InspectorClient supports tasks (e.g. `callToolStream()`, task RPCs, signal events). **Requestor task list** state is in optional state managers (e.g. PagedRequestorTasksState). Web client supports tasks; TUI does not yet have UI for task management. See [Task Support Design](./task-support-design.md) for implementation details. + +## Related Documentation + +- [Shared Code Architecture](./shared-code-architecture.md) - Overall architecture and integration plan +- [InspectorClient Details](./inspector-client-details.svg) - Visual diagram of InspectorClient responsibilities +- [Task Support Design](./task-support-design.md) - Design and implementation plan for Task support +- [MCP Clients Feature Support](https://modelcontextprotocol.info/docs/clients/) - High-level overview of MCP feature support across different clients + +## OAuth in TUI + +Hosted everything test server: https://example-server.modelcontextprotocol.io/mcp + +- Works from web client and TUI +- Determine if it's using DCR or CIMD + - Whichever one, find server that uses the other + +GitHub: https://github.com/github/github-mcp-server/ + +- This fails discovery in web client - appears related to CORS: https://github.com/modelcontextprotocol/inspector/issues/995 +- Test in TUI + +Guided auth + +- Try it in web ux to see how it works + - Record steps and output at each step +- Implement in TUI + +Let's make the "OAuth complete. You can close this window." page a little fancier + +Auth step change give prev/current step, use client.getOAuthState() to get current state (is automatically update as state machine progresses) + +Guided: + +| previousStep | step | state (payload — delta for this transition) | +| ------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `metadata_discovery` | `client_registration` | `resourceMetadata?`, `resource?`, `resourceMetadataError?`, `authServerUrl`, `oauthMetadata`, `oauthStep: "client_registration"` | +| `client_registration` | `authorization_redirect` | `oauthClientInfo`, `oauthStep: "authorization_redirect"` | +| `authorization_redirect` | `authorization_code` | `authorizationUrl`, `oauthStep: "authorization_code"` | +| `authorization_code` | `token_request` | `validationError: null` (or error string if code missing), `oauthStep: "token_request"` | +| `token_request` | `complete` | `oauthTokens`, `oauthStep: "complete"` | + +Normal: + +| When | oauthStep | getOAuthState() — populated fields | +| ------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------- | +| Before `authenticate()` | — | `undefined` (no state) | +| After `authenticate()` returns | `authorization_code` | `authType: "normal"`, `oauthStep: "authorization_code"`, `authorizationUrl`, `oauthClientInfo` | +| After `completeOAuthFlow()` succeeds | `complete` | `oauthStep: "complete"`, `oauthTokens`, `completedAt`. | + +Discovery fields (`resourceMetadata`, `oauthMetadata`, `authServerUrl`, `resource`) are null. + +Look at how web client displays Auth info (tab?) + +- We might want to have am Auth tab to show auth details + - Will differ between normal and guided (per above tables) + +## Issues + +Web client / proxy + +When attempting to auth to GitHub, it failed to be able to read the auth server metatata due to CORS + +- auth takes a fetch function for this purpose, and that fetch funciton needs to run in Node (not the browser) for this to work + +When attempting to connect in direct mode to the hosted "everything" server it failed because a CORS issue blocked the mcp-session-id response header from the initialize message + +- This can be addressed by running in proxy mode diff --git a/docs/v1.5-differences.md b/docs/v1.5-differences.md new file mode 100644 index 000000000..ea29a6e39 --- /dev/null +++ b/docs/v1.5-differences.md @@ -0,0 +1,45 @@ +# MCP Inspector v1.5 Branch + +## Key Differences + +`npm run build` - builds everything (as before) + +`npm run test:unit` - runs all tests (all tests now use vitest) + +`MCP_INSPECTOR_API_TOKEN` is now required to render the main ux (the config API endpoint is protected, as are all others) + +- Unless `DANGEROUSLY_OMIT_AUTH` is set, if you don't have an `MCP_INSPECTOR_API_TOKEN` as a URL param, you will be prompted to enter it before we render the main ux +- When `MCP_INSPECTOR_API_TOKEN` is a URL param, we parse it and reset the URL so you have a clean URL (you can still see the token in the config panel) + +There are new top-level npm targets that launch the web app via the CLI (to support the `--config` and `--server` params): + +- `npm run web` - runs CLI with `--web` +- `npm run web:dev` - runs CLI with `--web --dev` + +The CLI launcher has been modified so that if `server` is specified without `config` and an mcp.json exists in the CWD, that will be used by default + +This means: If you have an mpc.json in the root you can do: + +- `npm run web -- --server everything` - run the everything server from the mcp.json in the root + +There are some test mcp.json files in `configs/`: + +- `configs/mcp.json` - Sample test servers +- `configs/mcpapps.json` - All MCP Apps test servers + +You can use them (or any mcp.json file) like this: + +- `npm run web -- --config configs/mcpapps.json --server basic-solid` + +Note: In both cases above, you could also have used `web:dev` instead `web` + +You can run `dev` and `start` targets as before + +- `npm start -- ` + +There are also a couple of new env vars: + +| This repo | Description | Backward compat | +| ------------------------- | ---------------------------------------- | ----------------------------------------------------------------------- | +| `MCP_INSPECTOR_API_TOKEN` | API/auth token for the Inspector server. | Also accepts `MCP_PROXY_AUTH_TOKEN`. Uses random value if not provided. | +| `MCP_SANDBOX_PORT` | App sandbox server port. | Also accepts `SERVER_PORT`. Uses dynamic port if not provided. | diff --git a/docs/web-client-port-plan.md b/docs/web-client-port-plan.md new file mode 100644 index 000000000..e36418148 --- /dev/null +++ b/docs/web-client-port-plan.md @@ -0,0 +1,1896 @@ +# Web Client Port to InspectorClient - Step-by-Step Plan + +## Overview + +This document provides a step-by-step plan for porting the `web/` application to use `InspectorClient` instead of `useConnection`, integrating the Hono remote API server directly into Vite (eliminating the separate Express server), and ensuring functional parity with the existing `client/` application. + +**Goal:** The `web/` app should function identically to `client/` but use `InspectorClient` and the integrated Hono server instead of `useConnection` and the separate Express proxy. + +## Progress Summary + +- āœ… **Phase 1:** Integrate Hono Server into Vite - **COMPLETE** +- āœ… **Phase 2:** Create Web Client Adapter - **COMPLETE** +- āœ… **Phase 3:** Replace useConnection with InspectorClient - **COMPLETE** (All steps complete) + cd web- āœ… **Phase 4:** OAuth Integration - **COMPLETE** (All OAuth tests rewritten and passing) +- āœ… **Phase 5:** Remove Express Server Dependency - **COMPLETE** (Express proxy completely removed, Hono server handles all functionality) +- āøļø **Phase 6:** Testing and Validation - **IN PROGRESS** (Unit tests passing, integration testing remaining) +- āøļø **Phase 7:** Cleanup - **PARTIALLY COMPLETE** (useConnection removed, console.log cleaned up) + +**Current Status:** Core InspectorClient integration complete. OAuth integration complete with all tests rewritten. Express proxy completely removed. All unit tests passing (396 tests). Remaining work: Integration testing (Phase 6) and final cleanup (Phase 7). + +**Reference Documents:** + +- [Environment Isolation](./environment-isolation.md) - Details on remote infrastructure and seams +- [Shared Code Architecture](./shared-code-architecture.md) - High-level architecture and integration strategy +- [TUI Web Client Feature Gaps](./tui-web-client-feature-gaps.md) - Feature comparison + +--- + +## Phase 1: Integrate Hono Server into Vite āœ… COMPLETE + +**Goal:** Integrate the Hono remote API server into Vite (dev) and create a production server, making `/api/*` endpoints available. The web app will continue using the existing proxy/useConnection during this phase, allowing us to validate that the new API endpoints are working before migrating the app to use them. + +**Status:** āœ… Complete - Hono server integrated into Vite dev mode and production server created. Both Express proxy and Hono server run simultaneously. + +**Validation:** After Phase 1, you should be able to: + +- Start the dev server: Vite serves static files + Hono middleware handles `/api/*` routes, Express proxy runs separately +- Start the production server: Hono server (`bin/server.js`) serves static files + `/api/*` routes, Express proxy runs separately +- The existing web app continues to work normally in both dev and prod (still uses Express proxy for API calls) +- Hono endpoints (`/api/*`) are available and can be tested, but web app doesn't use them yet + +--- + +### Step 1.1: Create Vite Plugin for Hono Middleware āœ… COMPLETE + +**File:** `web/vite.config.ts` + +**Status:** āœ… Complete + +Create a Vite plugin that adds Hono middleware to handle `/api/*` routes. This runs alongside the existing Express proxy server (which the web app still uses). + +**As-Built:** + +- Implemented `honoMiddlewarePlugin` that mounts Hono middleware at root and checks for `/api` prefix +- Fixed Connect middleware path stripping issue by mounting at root and checking path manually +- Auth token passed via `process.env.MCP_INSPECTOR_API_TOKEN` (read-only, set by start script) + +```typescript +import { defineConfig, Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { randomBytes } from "node:crypto"; +import type { ConnectMiddleware } from "vite"; + +function honoMiddlewarePlugin(authToken: string): Plugin { + return { + name: "hono-api-middleware", + configureServer(server) { + // createRemoteApp returns { app, authToken } - we pass authToken explicitly + // If not provided, it will read from env or generate one + const { app: honoApp, authToken: resolvedAuthToken } = createRemoteApp({ + authToken, // Pass token explicitly (from start script) + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: [ + `http://localhost:${process.env.CLIENT_PORT || "6274"}`, + `http://127.0.0.1:${process.env.CLIENT_PORT || "6274"}`, + ], + logger: process.env.MCP_LOG_FILE + ? createFileLogger({ logPath: process.env.MCP_LOG_FILE }) + : undefined, + }); + + // Store resolved token for potential use (though we already have it) + // This ensures we use the same token that createRemoteApp is using + const finalAuthToken = authToken || resolvedAuthToken; + + // Convert Connect middleware to handle Hono app + const honoMiddleware: ConnectMiddleware = async (req, res, next) => { + try { + // Convert Node req/res to Web Standard Request + const url = `http://${req.headers.host}${req.url}`; + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) { + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + }); + + const init: RequestInit = { + method: req.method, + headers, + }; + + // Handle body for non-GET requests + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + await new Promise((resolve) => { + req.on("end", () => resolve()); + }); + if (chunks.length > 0) { + init.body = Buffer.concat(chunks); + } + } + + const request = new Request(url, init); + const response = await honoApp.fetch(request); + + // Convert Web Standard Response back to Node res + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (response.body) { + const reader = response.body.getReader(); + const pump = async () => { + const { done, value } = await reader.read(); + if (done) { + res.end(); + } else { + res.write(Buffer.from(value)); + await pump(); + } + }; + await pump(); + } else { + res.end(); + } + } catch (error) { + next(error); + } + }; + + server.middlewares.use("/api", honoMiddleware); + }, + }; +} + +export default defineConfig({ + plugins: [ + react(), + // Auth token is passed via env var (read-only, set by start script) + // Vite plugin reads it and passes explicitly to createRemoteApp + honoMiddlewarePlugin(process.env.MCP_INSPECTOR_API_TOKEN || ""), + ], + // ... rest of config +}); +``` + +**Dependencies needed:** + +- `@modelcontextprotocol/inspector-core` (already in workspace) +- `node:crypto` for `randomBytes` + +**Auth Token Handling:** + +The canonical environment variable for the API/auth token is `MCP_INSPECTOR_API_TOKEN`. `MCP_PROXY_AUTH_TOKEN` is accepted for backward compatibility. If neither is set, a random token is generated. + +The auth token flow: + +1. **Start script (`bin/start.js`)**: Reads `process.env.MCP_INSPECTOR_API_TOKEN` (or `MCP_PROXY_AUTH_TOKEN`) or generates one +2. **Vite plugin**: Receives token via env var (read-only, passed to spawned process). Plugin reads it and passes explicitly to `createRemoteApp()` +3. **Client browser**: Receives token via URL params (`?MCP_INSPECTOR_API_TOKEN=...`) + +**Key principle:** We never write to `process.env` to pass values between our own code. The token is: + +- Generated/read once in the start script +- Passed explicitly to Vite via env var (read-only, for the spawned process) +- Passed explicitly to `createRemoteApp()` via function parameter +- Passed to client via URL params + +**Testing:** + +- Start dev server: `npm run dev` (this will start both Vite with Hono middleware AND the Express proxy) +- Verify `/api/mcp/connect` endpoint responds (should return 401 without auth token) + - Test: `curl http://localhost:6274/api/mcp/connect` (should return 401) +- Verify `/api/fetch` endpoint exists: `curl http://localhost:6274/api/fetch` +- Verify `/api/log` endpoint exists: `curl http://localhost:6274/api/log` +- Check browser console for errors (should be none - web app still uses Express proxy) +- Verify auth token is passed to client via URL params (for future use) +- **Important:** The web app should still work normally using the Express proxy - we're just validating the new endpoints exist + +--- + +### Step 1.2: Create Production Server āœ… COMPLETE + +**File:** `web/bin/server.js` (new file) + +**Status:** āœ… Complete + +Create a production server that serves static files and API routes: + +**As-Built:** + +- Created `web/bin/server.js` that serves static files and routes `/api/*` to `apiApp` +- Static files served without authentication, API routes require auth token +- Auth token read from `process.env.MCP_INSPECTOR_API_TOKEN` + +```typescript +#!/usr/bin/env node + +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { randomBytes } from "node:crypto"; +import { createFileLogger } from "@modelcontextprotocol/inspector-core/mcp/node/logger"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const distPath = join(__dirname, "../dist"); + +const app = new Hono(); + +// Read auth token from env (provided by start script via spawn env) +// createRemoteApp will use this, or generate one if not provided +// The token is passed explicitly from start script, not written to process.env +const authToken = + process.env.MCP_INSPECTOR_API_TOKEN || randomBytes(32).toString("hex"); + +// Note: createRemoteApp returns the authToken it uses, so we could also +// let it generate one and return it, but for consistency we generate/read it here + +// Add API routes first (more specific) +const port = parseInt(process.env.CLIENT_PORT || "6274", 10); +const host = process.env.HOST || "localhost"; +const baseUrl = `http://${host}:${port}`; + +const { app: apiApp } = createRemoteApp({ + authToken, + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") || [baseUrl], + logger: process.env.MCP_LOG_FILE + ? createFileLogger({ logPath: process.env.MCP_LOG_FILE }) + : undefined, +}); +app.route("/api", apiApp); + +// Then add static file serving (fallback for SPA routing) +app.use( + "/*", + serveStatic({ + root: distPath, + rewriteRequestPath: (path) => { + // If path doesn't exist and doesn't have extension, serve index.html (SPA routing) + if (!path.includes(".") && !path.startsWith("/api")) { + return "/index.html"; + } + return path; + }, + }), +); + +serve( + { + fetch: app.fetch, + port, + hostname: host, + }, + (info) => { + console.log( + `\nšŸš€ MCP Inspector Web is up and running at:\n http://${host}:${info.port}\n`, + ); + console.log(` Auth token: ${authToken}\n`); + }, +); +``` + +**Update `web/package.json`:** + +- Add `start` script: `"start": "node bin/server.js"` +- Ensure `bin/server.js` is executable (chmod +x) + +**Dependencies needed:** + +- `@hono/node-server` (add to `web/package.json`) + +**Testing:** + +- Build: `npm run build` +- Start: `npm start` (via `bin/start.js` - starts Hono server for static files + `/api/*` endpoints, AND Express proxy) +- Verify static files serve correctly: `curl http://localhost:6274/` (should return index.html from Hono server) +- Verify `/api/mcp/connect` endpoint works: `curl http://localhost:6274/api/mcp/connect` (should return 401) +- Verify `/api/fetch` endpoint exists: `curl http://localhost:6274/api/fetch` +- Verify `/api/log` endpoint exists: `curl http://localhost:6274/api/log` +- Verify auth token is logged/available +- **Note:** Both Hono server (serving static files) and Express proxy run simultaneously. Web app uses Express proxy for API calls, but static files come from Hono server. + +--- + +### Step 1.3: Update Start Script (Keep Express Proxy for Now) āœ… COMPLETE + +**File:** `web/bin/start.js` + +**Status:** āœ… Complete + +**Important:** During Phase 1, both servers run: + +**As-Built:** + +- Start script generates both `proxySessionToken` (for Express) and `honoAuthToken` (for Hono) +- Tokens passed explicitly via environment variables (for spawned processes) and URL params (for browser) +- Both Express proxy and Vite/Hono server run simultaneously in dev/prod mode + +- **Hono server**: Serves static files (dev: via Vite middleware, prod: via `bin/server.js`) + `/api/*` endpoints +- **Express proxy**: Handles web app API calls (`/mcp`, `/stdio`, `/sse`, etc.) +- Web app loads static files from Hono server but makes API calls to Express proxy + +**Changes:** + +1. **Keep server spawning functions (for now):** + - Keep `startDevServer()` function (spawns Express proxy) + - Keep `startProdServer()` function (spawns Express proxy) + - Both Hono (via Vite middleware) and Express will run simultaneously in dev mode + +2. **Update `startDevClient()` to pass auth token to Vite:** + + ```typescript + async function startDevClient(clientOptions) { + const { CLIENT_PORT, honoAuthToken, abort, cancelled } = clientOptions; + const clientCommand = "npx"; + const host = process.env.HOST || "localhost"; + const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; + + const client = spawn(clientCommand, clientArgs, { + cwd: resolve(__dirname, ".."), + env: { + ...process.env, + CLIENT_PORT, + MCP_INSPECTOR_API_TOKEN: honoAuthToken, // Pass token to Vite (read-only) + // Note: Express proxy still uses MCP_PROXY_AUTH_TOKEN (different token) + }, + signal: abort.signal, + echoOutput: true, + }); + + // Include auth token in URL for client (Phase 3 will use this) + const params = new URLSearchParams(); + params.set("MCP_INSPECTOR_API_TOKEN", honoAuthToken); + const url = `http://${host}:${CLIENT_PORT}/?${params.toString()}`; + + setTimeout(() => { + console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); + console.log( + ` Static files served by: Vite (dev) / Hono server (prod)\n`, + ); + console.log(` Hono API endpoints: ${url}/api/*\n`); + console.log( + ` Express proxy: http://localhost:${SERVER_PORT} (web app API calls)\n`, + ); + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log("🌐 Opening browser..."); + open(url); + } + }, 3000); + + await new Promise((resolve) => { + client.subscribe({ + complete: resolve, + error: (err) => { + if (!cancelled || process.env.DEBUG) { + console.error("Client error:", err); + } + resolve(null); + }, + next: () => {}, + }); + }); + } + ``` + + **Note:** In dev mode, both servers run: + - Express proxy: `http://localhost:6277` (web app uses this) + - Hono API (via Vite): `http://localhost:6274/api/*` (available for validation) + +3. **Update `startProdClient()` - Use Hono server for static files:** + + ```typescript + async function startProdClient(clientOptions) { + const { CLIENT_PORT, honoAuthToken, abort } = clientOptions; + const honoServerPath = resolve(__dirname, "bin", "server.js"); + + // Hono server serves static files + /api/* endpoints + // Pass auth token explicitly via env var (read-only, server reads it) + await spawnPromise("node", [honoServerPath], { + env: { + ...process.env, + CLIENT_PORT, + MCP_INSPECTOR_API_TOKEN: honoAuthToken, // Pass token explicitly + }, + signal: abort.signal, + echoOutput: true, + }); + } + ``` + + **Note:** In Phase 1, prod mode uses Hono server to serve static files (just like Vite does in dev mode). Express proxy still runs separately for API calls. Web app loads static files from Hono server but makes API calls to Express proxy. Auth token is passed explicitly via env var (read-only). + +4. **Update `main()` function to run both servers in dev mode:** + + ```typescript + async function main() { + // ... parse args (same as before) ... + + const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; + const SERVER_PORT = + process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; + + // Generate auth tokens (separate tokens for Express proxy and Hono API) + const proxySessionToken = + process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); + const honoAuthToken = + process.env.MCP_INSPECTOR_API_TOKEN || randomBytes(32).toString("hex"); + + const abort = new AbortController(); + let cancelled = false; + process.on("SIGINT", () => { + cancelled = true; + abort.abort(); + }); + + let server, serverOk; + + if (isDev) { + // In dev mode: start Express proxy (web app uses this) AND Vite with Hono middleware + try { + const serverOptions = { + SERVER_PORT, + CLIENT_PORT, + sessionToken: proxySessionToken, + envVars, + abort, + command, + mcpServerArgs, + transport, + serverUrl, + }; + + const result = await startDevServer(serverOptions); + server = result.server; + serverOk = result.serverOk; + } catch (error) { + // Continue even if Express proxy fails - Hono API still works + console.warn("Express proxy failed to start:", error); + serverOk = false; + } + + if (serverOk) { + // Start Vite with Hono middleware (runs alongside Express proxy) + try { + const clientOptions = { + CLIENT_PORT, + SERVER_PORT, + honoAuthToken, // Pass Hono auth token explicitly + abort, + cancelled, + }; + await startDevClient(clientOptions); + } catch (e) { + if (!cancelled || process.env.DEBUG) throw e; + } + } + } else { + // In prod mode: start Express proxy (web app uses this) AND Hono server + try { + const serverOptions = { + SERVER_PORT, + CLIENT_PORT, + sessionToken: proxySessionToken, + envVars, + abort, + command, + mcpServerArgs, + transport, + serverUrl, + }; + + const result = await startProdServer(serverOptions); + server = result.server; + serverOk = result.serverOk; + } catch (error) { + console.warn("Express proxy failed to start:", error); + serverOk = false; + } + + if (serverOk) { + // Start Hono server (serves static files + /api/* endpoints) + try { + const clientOptions = { + CLIENT_PORT, + honoAuthToken, // Pass token explicitly + abort, + cancelled, + }; + await startProdClient(clientOptions); + } catch (e) { + if (!cancelled || process.env.DEBUG) throw e; + } + } + + // Both servers run: + // - Hono server (via startProdClient) serves static files + /api/* endpoints + // - Express proxy (via startProdServer) handles web app API calls + } + + return 0; + } + ``` + + **Key points:** + - In dev mode: Both Express proxy (port 6277) and Hono API (port 6274/api/\*) run simultaneously + - Web app continues using Express proxy (no changes needed yet) + - Hono API endpoints are available for validation/testing + - Separate auth tokens: `MCP_PROXY_AUTH_TOKEN` (Express) and `MCP_INSPECTOR_API_TOKEN` (Hono) + +--- + +## Phase 2: Create Web Client Adapter āœ… COMPLETE + +### Step 2.1: Create Config to MCPServerConfig Adapter āœ… COMPLETE + +**File:** `web/src/lib/adapters/configAdapter.ts` (new file) + +**Status:** āœ… Complete + +**Existing Code Reference:** + +- `client/src/components/Sidebar.tsx` has `generateServerConfig()` (lines 137-160) that converts web client format, but it's missing `type: "stdio"` and doesn't handle `customHeaders` +- `shared/mcp/node/config.ts` has `argsToMcpServerConfig()` for CLI format, but not web client format + +Create an adapter that converts the web client's configuration format to `MCPServerConfig`: + +```typescript +import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types"; +import type { CustomHeaders } from "../types/customHeaders"; + +export function webConfigToMcpServerConfig( + transportType: "stdio" | "sse" | "streamable-http", + command?: string, + args?: string, + sseUrl?: string, + env?: Record, + customHeaders?: CustomHeaders, +): MCPServerConfig { + switch (transportType) { + case "stdio": { + if (!command) { + throw new Error("Command is required for stdio transport"); + } + const config: MCPServerConfig = { + type: "stdio", + command, + }; + if (args?.trim()) { + config.args = args.split(/\s+/); + } + if (env && Object.keys(env).length > 0) { + config.env = env; + } + return config; + } + case "sse": { + if (!sseUrl) { + throw new Error("SSE URL is required for SSE transport"); + } + const headers: Record = {}; + customHeaders?.forEach((header) => { + if (header.enabled) { + headers[header.name] = header.value; + } + }); + const config: MCPServerConfig = { + type: "sse", + url: sseUrl, + }; + if (Object.keys(headers).length > 0) { + config.headers = headers; + } + return config; + } + case "streamable-http": { + if (!sseUrl) { + throw new Error("Server URL is required for streamable-http transport"); + } + const headers: Record = {}; + customHeaders?.forEach((header) => { + if (header.enabled) { + headers[header.name] = header.value; + } + }); + const config: MCPServerConfig = { + type: "streamable-http", + url: sseUrl, + }; + if (Object.keys(headers).length > 0) { + config.headers = headers; + } + return config; + } + } +} +``` + +**Note:** This is similar to `generateServerConfig()` in `Sidebar.tsx` but: + +- Adds `type: "stdio"` for stdio transport +- Converts `customHeaders` array to `headers` object (only enabled headers) +- Returns proper `MCPServerConfig` type (no `note` field) + +--- + +### Step 2.2: Create Environment Factory āœ… COMPLETE + +**File:** `web/src/lib/adapters/environmentFactory.ts` (new file) + +**Status:** āœ… Complete + +Create a factory function that builds the `InspectorClientEnvironment` object: + +**As-Built:** + +- Fixed "Illegal invocation" error by wrapping `window.fetch` to preserve `this` context: `const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args)` +- Uses `BrowserOAuthStorage` and `BrowserNavigation` for OAuth +- `redirectUrlProvider` consistently returns `/oauth/callback` regardless of mode (mode stored in state, not URL) + +```typescript +import type { InspectorClientEnvironment } from "@modelcontextprotocol/inspector-core/mcp/inspectorClient"; +import { createRemoteTransport } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteTransport"; +import { createRemoteFetch } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteFetch"; +import { createRemoteLogger } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteLogger"; +import { BrowserOAuthStorage } from "@modelcontextprotocol/inspector-core/auth/browser"; +import { BrowserNavigation } from "@modelcontextprotocol/inspector-core/auth/browser"; +import type { RedirectUrlProvider } from "@modelcontextprotocol/inspector-core/auth/types"; + +export function createWebEnvironment( + authToken: string | undefined, + redirectUrlProvider: RedirectUrlProvider, +): InspectorClientEnvironment { + const baseUrl = `${window.location.protocol}//${window.location.host}`; + + return { + transport: createRemoteTransport({ + baseUrl, + authToken, + fetchFn: window.fetch, + }), + fetch: createRemoteFetch({ + baseUrl, + authToken, + fetchFn: window.fetch, + }), + logger: createRemoteLogger({ + baseUrl, + authToken, + fetchFn: window.fetch, + }), + oauth: { + storage: new BrowserOAuthStorage(), // or RemoteOAuthStorage for shared state + navigation: new BrowserNavigation(), + redirectUrlProvider, + }, + }; +} +``` + +**Note:** Consider using `RemoteOAuthStorage` if you want shared OAuth state with TUI/CLI. The auth token should come from the Hono server (same token used to create the server). + +--- + +## Phase 3: Replace useConnection with InspectorClient āœ… COMPLETE + +### Step 3.1: Understand useInspectorClient Interface āœ… COMPLETE + +**Reference:** `shared/react/useInspectorClient.ts` + +**Status:** āœ… Complete - Hook interface understood and used throughout implementation + +The `useInspectorClient` hook returns: + +```typescript +interface UseInspectorClientResult { + status: ConnectionStatus; // 'disconnected' | 'connecting' | 'connected' | 'error' + messages: MessageEntry[]; + stderrLogs: StderrLogEntry[]; + fetchRequests: FetchRequestEntry[]; + tools: Tool[]; + resources: Resource[]; + resourceTemplates: ResourceTemplate[]; + prompts: Prompt[]; + capabilities?: ServerCapabilities; + serverInfo?: Implementation; + instructions?: string; + client: Client | null; // The underlying MCP SDK Client + connect: () => Promise; + disconnect: () => Promise; +} +``` + +**Note:** The hook uses `status` (not `connectionStatus`). You'll need to map this when replacing `useConnection` calls in components. + +--- + +### Step 3.2: Update App.tsx to Use InspectorClient āœ… COMPLETE + +**File:** `web/src/App.tsx` + +**Status:** āœ… Complete + +**Changes:** + +**As-Built:** + +- Removed local state syncing (`useEffect` blocks) for resources, prompts, tools, resourceTemplates +- Removed local state declarations - now using hook values directly (`inspectorResources`, `inspectorPrompts`, `inspectorTools`, `inspectorResourceTemplates`) +- Updated all component props to use hook values +- InspectorClient instance created in `useMemo` with proper dependencies +- Auth token extracted from URL params (`MCP_INSPECTOR_API_TOKEN`) +- `useInspectorClient` hook used to get all state and methods + +1. **Replace imports:** + + ```typescript + // Remove + import { useConnection } from "./lib/hooks/useConnection"; + + // Add + import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/inspectorClient"; + import { useInspectorClientWeb } from "./lib/hooks/useInspectorClientWeb"; + import { createWebEnvironment } from "./lib/adapters/environmentFactory"; + import { webConfigToMcpServerConfig } from "./lib/adapters/configAdapter"; + ``` + +2. **Get auth token and create InspectorClient instance:** + + ```typescript + // Get auth token from URL params (set by start script) or localStorage + const authToken = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get("MCP_INSPECTOR_API_TOKEN") || null; + }, []); + + const inspectorClient = useMemo(() => { + if (!command && !sseUrl) return null; // Can't create without config + if (!authToken) return null; // Need auth token for remote API + + const config = webConfigToMcpServerConfig( + transportType, + command, + args, + sseUrl, + env, + customHeaders, + ); + + const environment = createWebEnvironment(authToken, () => { + return `${window.location.origin}/oauth/callback`; + }); + + return new InspectorClient(config, { + environment, + autoFetchServerContents: true, // Match current behavior + maxMessages: 1000, + maxStderrLogEvents: 1000, + maxFetchRequests: 1000, + oauth: { + clientId: oauthClientId || undefined, + clientSecret: oauthClientSecret || undefined, + scope: oauthScope || undefined, + }, + }); + }, [ + transportType, + command, + args, + sseUrl, + env, + customHeaders, + oauthClientId, + oauthClientSecret, + oauthScope, + authToken, + ]); + ``` + +3. **Replace useConnection hook:** + + ```typescript + // Remove + const { connectionStatus, ... } = useConnection({ ... }); + + // Add + const { + status: connectionStatus, // Map 'status' to 'connectionStatus' for compatibility + tools, + resources, + prompts, + messages, + stderrLogs, + fetchRequests, + capabilities, + serverInfo, + instructions, + client: mcpClient, + connect: connectMcpServer, + disconnect: disconnectMcpServer, + } = useInspectorClient(inspectorClient); + ``` + +4. **Update connect/disconnect handlers:** + + ```typescript + // These are now provided by useInspectorClient hook: + // connectMcpServer and disconnectMcpServer are already available from the hook + // No need to create separate handlers unless you need custom logic + ``` + +5. **Update OAuth handlers:** + - Replace `useConnection` OAuth methods with `InspectorClient` methods: + - `authenticate()` → `inspectorClient.authenticate()` + - `completeOAuthFlow()` → `inspectorClient.completeOAuthFlow()` + - `getOAuthTokens()` → `inspectorClient.getOAuthTokens()` + +--- + +### Step 3.3: Migrate State Format āœ… COMPLETE + +**File:** `web/src/App.tsx` + +**Status:** āœ… Complete + +**Changes:** + +1. **Message History:** āœ… Complete + - **As-Built:** `requestHistory` now uses MCP protocol messages from `inspectorMessages` + - Filters `inspectorMessages` for `direction === "request"` (non-notification messages) + - Converts to format: `{ request: string, response?: string }[]` for `HistoryAndNotifications` component + - **Note:** History tab shows MCP protocol messages (requests/responses), not HTTP requests + +2. **Request History:** āœ… Complete + - **As-Built:** Not using `FetchRequestEntry[]` - instead using MCP protocol messages for History tab + - `fetchRequests` removed from hook destructuring (not needed for current UI) + +3. **Stderr Logs:** āœ… Complete + - `stderrLogs` destructured from hook and passed to `ConsoleTab` + - `ConsoleTab` displays `StderrLogEntry[]` with timestamps and messages + - **As-Built:** Console tab trigger added to UI, only shown when `transportType === "stdio"` (since stderr logs are only available for stdio transports) + - Console tab added to valid tabs list for routing + +4. **Server Data:** āœ… Complete + - Tools, Resources, Prompts: Using hook values directly (`inspectorTools`, `inspectorResources`, `inspectorPrompts`) + - Manual fetching logic removed - InspectorClient handles this automatically + +--- + +### Step 3.4: Update Notification Handlers āœ… COMPLETE + +**File:** `web/src/App.tsx` + +**Status:** āœ… Complete + +**Changes:** + +1. **Replace notification callbacks:** āœ… Complete + - **As-Built:** Notifications extracted from `inspectorMessages` via `useMemo` + `useEffect` with content comparison to prevent infinite loops: + + ```typescript + const extractedNotifications = useMemo(() => { + return inspectorMessages + .filter((msg) => msg.direction === "notification" && msg.message) + .map((msg) => msg.message as ServerNotification); + }, [inspectorMessages]); + + const previousNotificationsRef = useRef("[]"); + useEffect(() => { + const currentSerialized = JSON.stringify(extractedNotifications); + if (currentSerialized !== previousNotificationsRef.current) { + setNotifications(extractedNotifications); + previousNotificationsRef.current = currentSerialized; + } + }, [extractedNotifications]); + ``` + + - **Bug Fix:** Fixed infinite loop caused by `InspectorClient.getMessages()` returning new array references. Fixed in `useInspectorClient` hook by comparing serialized content before updating state. + - No separate event listeners needed - notifications come from message stream + +2. **Update request handlers:** āœ… Complete + - **Elicitation:** āœ… Complete - Using `inspectorClient.addEventListener("newPendingElicitation", ...)` + - **Sampling:** āœ… Complete - Using `inspectorClient.addEventListener("newPendingSample", ...)` + - **Roots:** āœ… Complete - Using `inspectorClient.getRoots()`, `inspectorClient.setRoots()`, and listening to `rootsChange` event + - `handleRootsChange()` calls `inspectorClient.setRoots(roots)` which handles sending notification internally + - Roots synced with InspectorClient via `useEffect` and `rootsChange` event listener + +3. **Stderr Logs:** āœ… Complete + - **As-Built:** `stderrLogs` destructured from `useInspectorClient` hook + - `ConsoleTab` component updated to accept and display `StderrLogEntry[]` + - Displays timestamp and message for each stderr log entry + - Shows "No stderr output yet" when empty + +--- + +### Step 3.5: Update Method Calls āœ… COMPLETE + +**File:** `web/src/App.tsx` and component files + +**Status:** āœ… Complete + +**Changes:** + +Replace all `mcpClient` method calls with `inspectorClient` methods: + +**As-Built:** + +- āœ… `listResources()` → `inspectorClient.listResources(cursor, metadata)` +- āœ… `listResourceTemplates()` → `inspectorClient.listResourceTemplates(cursor, metadata)` +- āœ… `readResource()` → `inspectorClient.readResource(uri, metadata)` +- āœ… `subscribeToResource()` → `inspectorClient.subscribeToResource(uri)` +- āœ… `unsubscribeFromResource()` → `inspectorClient.unsubscribeFromResource(uri)` +- āœ… `listPrompts()` → `inspectorClient.listPrompts(cursor, metadata)` +- āœ… `getPrompt()` → `inspectorClient.getPrompt(name, args, metadata)` (with JsonValue conversion) +- āœ… `listTools()` → `inspectorClient.listTools(cursor, metadata)` +- āœ… `callTool()` → `inspectorClient.callTool(name, args, generalMetadata, toolSpecificMetadata)` (with ToolCallInvocation → CompatibilityCallToolResult conversion) +- āœ… `sendLogLevelRequest()` → `inspectorClient.setLoggingLevel(level)` +- āœ… Ping → `mcpClient.request({ method: "ping" }, EmptyResultSchema)` (direct SDK call) +- āœ… Removed `sendMCPRequest()` wrapper function +- āœ… Removed `makeRequest()` wrapper function +- āœ… All methods include proper error handling with `clearError()` calls + +--- + +## Phase 4: OAuth Integration + +**Status:** āœ… COMPLETE + +**Goal:** Replace custom OAuth implementation (`InspectorOAuthClientProvider`, `OAuthStateMachine`, manual state management) with `InspectorClient`'s built-in OAuth support, matching the TUI implementation pattern. + +--- + +### Architecture Overview + +**Current State (Web App):** + +- Custom `InspectorOAuthClientProvider` and `DebugInspectorOAuthClientProvider` classes (`web/src/lib/auth.ts`) +- Custom `OAuthStateMachine` class (`web/src/lib/oauth-state-machine.ts`) - duplicates shared implementation +- Manual OAuth state management via `AuthGuidedState` in `App.tsx` +- `OAuthCallback` component uses SDK `auth()` directly +- `AuthDebugger` component uses custom state machine for guided flow +- `OAuthFlowProgress` component displays custom state + +**Target State (After Port):** + +- Use `InspectorClient` OAuth methods: `authenticate()`, `completeOAuthFlow()`, `beginGuidedAuth()`, `proceedOAuthStep()`, `getOAuthState()`, `getOAuthTokens()` +- Use shared `BrowserOAuthStorage` and `BrowserNavigation` (already configured in `createWebEnvironment`) +- Listen to `InspectorClient` OAuth events: `oauthStepChange`, `oauthComplete`, `oauthError` +- Remove custom OAuth providers and state machine +- Simplify components to read from `InspectorClient.getOAuthState()` + +**Reference Implementation (TUI):** + +- TUI uses `InspectorClient` OAuth methods directly +- Quick Auth: Creates callback server → sets redirect URL → calls `authenticate()` → waits for callback → calls `completeOAuthFlow()` +- Guided Auth: Calls `beginGuidedAuth()` → listens to `oauthStepChange` events → calls `proceedOAuthStep()` for each step +- OAuth state synced via `inspectorClient.getOAuthState()` and `oauthStepChange` events + +--- + +### Step 4.1: Follow TUI Pattern - Components Manage OAuth State Directly + +**Approach:** Follow the TUI pattern where components that need OAuth state manage it directly, rather than extending the hook. + +**Rationale:** TUI's `AuthTab` component doesn't use `useInspectorClient` for OAuth state. Instead, it: + +1. Receives `inspectorClient` as a prop +2. Uses `useState` to manage `oauthState` locally +3. Uses `useEffect` to sync state by calling `inspectorClient.getOAuthState()` directly +4. Listens to `oauthStepChange` and `oauthComplete` events directly on `inspectorClient` + +**Alternative Approach (Optional):** We could extend `useInspectorClient` to expose OAuth state, which would be more DRY if multiple components need it. However, since only `AuthDebugger` needs OAuth state in the web app, following the TUI pattern is simpler and more consistent. + +**Implementation Pattern (for AuthDebugger):** + +```typescript +const AuthDebugger = ({ inspectorClient, onBack }: AuthDebuggerProps) => { + const { toast } = useToast(); + const [oauthState, setOauthState] = useState( + undefined, + ); + const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); + + // Sync oauthState from InspectorClient (TUI pattern) + useEffect(() => { + if (!inspectorClient) { + setOauthState(undefined); + return; + } + + const update = () => setOauthState(inspectorClient.getOAuthState()); + update(); + + const onStepChange = () => update(); + inspectorClient.addEventListener("oauthStepChange", onStepChange); + inspectorClient.addEventListener("oauthComplete", onStepChange); + inspectorClient.addEventListener("oauthError", onStepChange); + + return () => { + inspectorClient.removeEventListener("oauthStepChange", onStepChange); + inspectorClient.removeEventListener("oauthComplete", onStepChange); + inspectorClient.removeEventListener("oauthError", onStepChange); + }; + }, [inspectorClient]); + + // OAuth methods call InspectorClient directly + const handleQuickOAuth = useCallback(async () => { + if (!inspectorClient) return; + setIsInitiatingAuth(true); + try { + await inspectorClient.authenticate(); + } catch (error) { + // Handle error + } finally { + setIsInitiatingAuth(false); + } + }, [inspectorClient]); + + const handleGuidedOAuth = useCallback(async () => { + if (!inspectorClient) return; + setIsInitiatingAuth(true); + try { + await inspectorClient.beginGuidedAuth(); + } catch (error) { + // Handle error + } finally { + setIsInitiatingAuth(false); + } + }, [inspectorClient]); + + const proceedToNextStep = useCallback(async () => { + if (!inspectorClient) return; + setIsInitiatingAuth(true); + try { + await inspectorClient.proceedOAuthStep(); + } catch (error) { + // Handle error + } finally { + setIsInitiatingAuth(false); + } + }, [inspectorClient]); + + // ... rest of component uses oauthState ... +}; +``` + +**Benefits of This Approach:** + +- Matches TUI implementation exactly +- No changes needed to shared hook (avoids affecting TUI) +- Simpler - OAuth state only where needed +- Components have direct access to `inspectorClient` methods + +**Note:** If we later need OAuth state in multiple components, we can refactor to extend the hook then. For now, following TUI pattern is the simplest path. + +--- + +### Step 4.2: Update OAuth Callback Component (Single Redirect URL Approach) + +**File:** `web/src/components/OAuthCallback.tsx` + +**Current Implementation:** + +- Uses `InspectorOAuthClientProvider` + SDK `auth()` function +- Reads `serverUrl` from `sessionStorage` +- Calls `onConnect(serverUrl)` after success + +**As-Built Implementation:** + +**Key Design Decision: Single Redirect URL with State Parameter** + +- Uses a single `/oauth/callback` endpoint for both normal and guided modes +- Mode is encoded in the OAuth `state` parameter: `"guided:{random}"` or `"normal:{random}"` +- Matches TUI implementation pattern (no separate debug endpoint) + +**Changes:** + +1. **Remove custom provider imports:** + + ```typescript + // Remove + import { InspectorOAuthClientProvider } from "../lib/auth"; + import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; + import { SESSION_KEYS } from "../lib/constants"; + + // Add + import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; + import { parseOAuthState } from "@modelcontextprotocol/inspector-core/auth/index.js"; + ``` + +2. **Update component props:** + + ```typescript + interface OAuthCallbackProps { + inspectorClient: InspectorClient | null; + ensureInspectorClient: () => InspectorClient | null; + onConnect: () => void; + } + ``` + +3. **Guided Mode Handling (New Tab Scenario):** + - When callback occurs in a new tab without `InspectorClient` context: + - Parse `state` parameter to detect guided mode + - Display authorization code for manual copying + - Store code in `sessionStorage` for potential auto-fill (future enhancement) + - **Do NOT redirect** - user must manually copy code and return to Guided Auth flow + - Message: "Please copy this authorization code and return to the Guided Auth flow:" + +4. **Guided Mode Handling (Same Tab Scenario):** + - When callback occurs in same tab with `InspectorClient` available: + - Call `client.setGuidedAuthorizationCode(code, false)` to set code without auto-completing + - Show toast notification + - **Do NOT redirect** - user controls progression manually + +5. **Normal Mode Handling:** + - Call `client.completeOAuthFlow(code)` to complete flow automatically + - Trigger auto-connect + - Redirect to root (`/`) after completion + +**Key Implementation Details:** + +- Uses `parseOAuthState()` to extract mode from `state` parameter +- Early return for guided mode without client to avoid "API token required" toast +- Remote logging via `InspectorClient.logger` (persists through redirects) +- Handles both `/oauth/callback` and `/` paths (some auth servers redirect incorrectly) + +**Key Changes:** + +- Removed `sessionStorage` dependency (server URL comes from `InspectorClient` config) +- Replaced `InspectorOAuthClientProvider` + `auth()` with `inspectorClient.completeOAuthFlow()` or `setGuidedAuthorizationCode()` +- Single callback endpoint handles both modes via state parameter +- Guided mode shows code for manual copying (no redirect) +- Normal mode auto-completes and redirects + +--- + +### Step 4.3: Remove OAuth Debug Callback Component (Consolidated) + +**File:** `web/src/components/OAuthDebugCallback.tsx` + +**Status:** āœ… Removed - functionality consolidated into `OAuthCallback.tsx` + +**Rationale:** + +- Single redirect URL approach eliminates need for separate debug endpoint +- Guided mode is handled via state parameter in single callback +- Component removed, functionality merged into `OAuthCallback` + +--- + +### Step 4.4: Update App.tsx OAuth Routes and Handlers + +**File:** `web/src/App.tsx` + +**Current Implementation:** + +- Routes `/oauth/callback` and `/oauth/callback/debug` render callback components +- `onOAuthConnect` and `onOAuthDebugConnect` handlers manage state +- OAuth config (clientId, clientSecret, scope) stored in component state + +**As-Built Changes:** + +1. **Single OAuth callback route:** + + ```typescript + // Handle both /oauth/callback and / paths (some auth servers redirect incorrectly) + const hasOAuthCallbackParams = urlParams.has("code") || urlParams.has("error"); + + if ( + window.location.pathname === "/oauth/callback" || + (hasOAuthCallbackParams && window.location.pathname === "/") + ) { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...
}> + + + ); + } + ``` + +2. **Removed `/oauth/callback/debug` route** - consolidated into single callback + +3. **OAuth handlers:** + - `onOAuthConnect`: Calls `connectMcpServer()` after successful OAuth + - Quick Auth handled in `AuthDebugger` component (calls `client.authenticate()`) + - Guided Auth handled in `AuthDebugger` component (calls `client.beginGuidedAuth()`) + +4. **InspectorClient Creation Strategy:** + - Uses `ensureInspectorClient()` helper for lazy creation + - Validates API token before creating client + - Shows toast error if API token missing + - Client created on-demand when needed (connect or auth operations) + +**Key Implementation Details:** + +- Single callback endpoint handles both normal and guided modes +- Callback routing handles both `/oauth/callback` and `/` paths (auth server compatibility) +- `BrowserNavigation` automatically redirects for quick auth +- Guided auth uses manual code entry (no auto-redirect) + +--- + +### Step 4.5: Refactor AuthDebugger Component to Use InspectorClient + +**File:** `web/src/components/AuthDebugger.tsx` + +**Current Implementation:** + +- Uses custom `OAuthStateMachine` and `DebugInspectorOAuthClientProvider` +- Manages `AuthGuidedState` manually via `updateAuthState` +- Has "Guided OAuth Flow" and "Quick OAuth Flow" buttons + +**Changes:** + +1. **Update component props:** + + ```typescript + interface AuthDebuggerProps { + inspectorClient: InspectorClient | null; + onBack: () => void; + } + ``` + +2. **Remove custom state machine and provider:** + + ```typescript + // Remove + import { OAuthStateMachine } from "../lib/oauth-state-machine"; + import { DebugInspectorOAuthClientProvider } from "../lib/auth"; + import type { AuthGuidedState } from "../lib/auth-types"; + + // Add + import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; + import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; + ``` + +3. **Follow TUI pattern - manage OAuth state directly:** + + ```typescript + const AuthDebugger = ({ + inspectorClient, + onBack, + }: AuthDebuggerProps) => { + const { toast } = useToast(); + const [oauthState, setOauthState] = useState( + undefined, + ); + const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); + + // Sync oauthState from InspectorClient (TUI pattern - Step 4.1) + useEffect(() => { + if (!inspectorClient) { + setOauthState(undefined); + return; + } + + const update = () => setOauthState(inspectorClient.getOAuthState()); + update(); + + const onStepChange = () => update(); + inspectorClient.addEventListener("oauthStepChange", onStepChange); + inspectorClient.addEventListener("oauthComplete", onStepChange); + inspectorClient.addEventListener("oauthError", onStepChange); + + return () => { + inspectorClient.removeEventListener("oauthStepChange", onStepChange); + inspectorClient.removeEventListener("oauthComplete", onStepChange); + inspectorClient.removeEventListener("oauthError", onStepChange); + }; + }, [inspectorClient]); + ``` + +4. **Update Quick OAuth handler:** + + ```typescript + const handleQuickOAuth = useCallback(async () => { + if (!inspectorClient) return; + + setIsInitiatingAuth(true); + try { + // Quick Auth: normal flow (automatic redirect via BrowserNavigation) + await inspectorClient.authenticate(); + // BrowserNavigation handles redirect automatically + } catch (error) { + console.error("Quick OAuth failed:", error); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [inspectorClient, toast]); + ``` + +5. **Update Guided OAuth handler:** + + ```typescript + const handleGuidedOAuth = useCallback(async () => { + if (!inspectorClient) return; + + setIsInitiatingAuth(true); + try { + // Start guided flow + await inspectorClient.beginGuidedAuth(); + // State updates via oauthStepChange events (handled in useEffect above) + } catch (error) { + console.error("Guided OAuth start failed:", error); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [inspectorClient, toast]); + ``` + +6. **Update proceed to next step handler:** + + ```typescript + const proceedToNextStep = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client || !oauthState) return; + + setIsInitiatingAuth(true); + try { + await client.proceedOAuthStep(); + // Note: For guided flow, users manually copy the authorization code. + // There's a manual button in OAuthFlowProgress to open the URL if needed. + // Quick auth handles redirects automatically via BrowserNavigation. + } catch (error) { + console.error("OAuth step failed:", error); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [ensureInspectorClient, oauthState, toast]); + ``` + + **Key Change:** Removed auto-opening of authorization URL. In guided flow, users manually copy the code. There's a manual button (external link icon) in `OAuthFlowProgress` at the `authorization_redirect` step if users want to open the URL. + +7. **Update clear OAuth handler:** + + ```typescript + const handleClearOAuth = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client) return; + + client.clearOAuthTokens(); + toast({ + title: "OAuth Cleared", + description: "OAuth tokens have been cleared", + variant: "default", + }); + }, [ensureInspectorClient, toast]); + ``` + + **Note:** Uses `InspectorClient.clearOAuthTokens()` method which clears tokens from storage. + +8. **Update component props and ensureInspectorClient pattern:** + + ```typescript + interface AuthDebuggerProps { + inspectorClient: InspectorClient | null; + ensureInspectorClient: () => InspectorClient | null; + canCreateInspectorClient: () => boolean; + onBack: () => void; + } + ``` + + - Uses `ensureInspectorClient()` helper for lazy client creation + - Validates API token before creating client + - Buttons enabled when `canCreateInspectorClient()` returns true (even if `inspectorClient` is null) + +9. **Check for existing tokens on mount:** + + ```typescript + useEffect(() => { + if (inspectorClient && !oauthState?.oauthTokens) { + inspectorClient.getOAuthTokens().then((tokens) => { + if (tokens) { + // State will be updated via getOAuthState() in sync effect + setOauthState(inspectorClient.getOAuthState()); + } + }); + } + }, [inspectorClient, oauthState]); + ``` + +--- + +### Step 4.6: Update OAuthFlowProgress Component + +**File:** `web/src/components/OAuthFlowProgress.tsx` + +**Current Implementation:** + +- Receives `authState`, `updateAuthState`, and `proceedToNextStep` as props +- Uses custom `DebugInspectorOAuthClientProvider` to fetch client info + +**As-Built Changes:** + +1. **Update component props:** + + ```typescript + interface OAuthFlowProgressProps { + oauthState: AuthGuidedState | undefined; + proceedToNextStep: () => Promise; + ensureInspectorClient: () => InspectorClient | null; + } + ``` + + **Note:** Component receives `oauthState` as prop (from `AuthDebugger`'s local state) rather than accessing `inspectorClient` directly. This keeps the component simpler and follows React best practices. + +2. **Remove custom provider usage:** + + ```typescript + // Remove + import { DebugInspectorOAuthClientProvider } from "../lib/auth"; + + // Add + import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; + import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; + ``` + +3. **Manual Authorization Code Entry:** + - Added input field at `authorization_code` step for manual code entry + - Uses `localAuthCode` state synchronized with `oauthState.authorizationCode` + - `onBlur` and `Enter` key: calls `client.setGuidedAuthorizationCode(code, false)` + - "Continue" button checks if code needs to be set before proceeding + +4. **Manual URL Opening Button:** + - External link icon button at `authorization_redirect` step + - Opens authorization URL in new tab when clicked + - No auto-opening - user must manually click button + +5. **Update step rendering to use `oauthState`:** + + ```typescript + // Replace `authState` references with `oauthState` + const currentStepIdx = steps.findIndex((s) => s === oauthState?.oauthStep); + + const getStepProps = (stepName: OAuthStep) => ({ + isComplete: + currentStepIdx > steps.indexOf(stepName) || + currentStepIdx === steps.length - 1, + isCurrent: oauthState?.oauthStep === stepName, + error: oauthState?.oauthStep === stepName ? oauthState.latestError : null, + }); + ``` + +6. **Update `AuthDebugger` to pass props:** + + ```typescript + // In AuthDebugger component: + + ``` + +**Key Implementation Details:** + +- Manual code entry matches TUI UX pattern +- No auto-opening of authorization URL (removed from `proceedToNextStep`) +- Manual button available for users who want to open URL +- Code synchronization between input field and `InspectorClient` state + +--- + +### Step 4.7: Remove Custom OAuth Code + +**Files to Delete:** + +- `web/src/lib/auth.ts` - Custom `InspectorOAuthClientProvider` and `DebugInspectorOAuthClientProvider` +- `web/src/lib/oauth-state-machine.ts` - Custom `OAuthStateMachine` (duplicates shared implementation) +- `web/src/lib/auth-types.ts` - Custom `AuthGuidedState` type (use shared type instead) + +**Files to Update:** + +- `web/src/components/AuthDebugger.tsx` - Remove imports of deleted files +- `web/src/components/OAuthFlowProgress.tsx` - Remove imports of deleted files +- `web/src/App.tsx` - Remove imports of deleted files, remove `authState` state management + +**Note:** `web/src/utils/oauthUtils.ts` (OAuth URL parsing utilities) should be kept as it's still needed. + +--- + +### Step 4.8: Update Environment Factory (Already Complete) + +**File:** `web/src/lib/adapters/environmentFactory.ts` + +**Status:** āœ… Already configured correctly + +The environment factory already uses `BrowserOAuthStorage` and `BrowserNavigation` from shared: + +```typescript +import { + BrowserOAuthStorage, + BrowserNavigation, +} from "@modelcontextprotocol/inspector-core/auth/browser/index.js"; + +export function createWebEnvironment( + authToken: string | undefined, + redirectUrlProvider: RedirectUrlProvider, +): InspectorClientEnvironment { + // ... + oauth: { + storage: new BrowserOAuthStorage(), + navigation: new BrowserNavigation(), + redirectUrlProvider, + }, +} +``` + +**No changes needed.** + +--- + +### Step 4.9: Update Tests + +**Files to Update:** + +- `web/src/components/__tests__/AuthDebugger.test.tsx` - Mock `InspectorClient` OAuth methods instead of custom providers +- `web/src/components/__tests__/OAuthCallback.test.tsx` (if exists) - Update to use `InspectorClient` +- `web/src/__tests__/App.config.test.tsx` - Verify OAuth config is passed to `InspectorClient` + +**Test Strategy:** + +1. Mock `InspectorClient` methods: `authenticate()`, `completeOAuthFlow()`, `beginGuidedAuth()`, `proceedOAuthStep()`, `getOAuthState()`, `getOAuthTokens()` +2. Test that OAuth callbacks call `inspectorClient.completeOAuthFlow()` with correct code +3. Test that guided flow calls `beginGuidedAuth()` and `proceedOAuthStep()` correctly +4. Test that OAuth state updates via `oauthStepChange` events + +--- + +### Implementation Order (As-Built) + +1. **Step 4.1:** Follow TUI pattern - components manage OAuth state directly (no hook changes needed) āœ… +2. **Step 4.2:** Update `OAuthCallback` component - single redirect URL with state parameter āœ… +3. **Step 4.3:** Remove `OAuthDebugCallback` component - consolidated into single callback āœ… +4. **Step 4.4:** Update `App.tsx` routes - single callback route, `ensureInspectorClient` pattern āœ… +5. **Step 4.5:** Refactor `AuthDebugger` component - OAuth state management, lazy client creation āœ… +6. **Step 4.6:** Update `OAuthFlowProgress` component - manual code entry, manual URL button āœ… +7. **Step 4.7:** Remove custom OAuth code (cleanup) āœ… +8. **Step 4.9:** Update tests - all tests rewritten and passing āœ… + +**Dependencies:** + +- Step 4.1 is a pattern decision (no code changes) - components will manage OAuth state directly +- Steps 4.2-4.4 can be done independently (they don't need OAuth state) +- Step 4.5 implements the OAuth state management pattern from Step 4.1 +- Step 4.6 depends on Step 4.5 (receives `oauthState` as prop) +- Step 4.7 should be done last (after all components updated) +- Step 4.9 should be done alongside component updates + +--- + +### Migration Notes + +**Breaking Changes:** + +- `OAuthCallback` now requires `inspectorClient` and `ensureInspectorClient` props +- `OAuthDebugCallback` removed (consolidated into `OAuthCallback`) +- `AuthDebugger` no longer uses `authState` prop (reads from `InspectorClient`) +- `OAuthFlowProgress` no longer uses `authState` prop +- Single redirect URL (`/oauth/callback`) for both normal and guided modes + +**Backward Compatibility:** + +- OAuth redirect URL changed: single `/oauth/callback` endpoint (removed `/oauth/callback/debug`) +- OAuth storage location remains the same (sessionStorage via `BrowserOAuthStorage`) +- OAuth flow behavior remains the same (normal vs guided), but mode determined by state parameter +- Guided mode callback shows code for manual copying (matches TUI UX) + +**Testing Checklist:** + +- [x] Quick OAuth flow (normal mode) works end-to-end - āœ… Tests written and passing +- [x] Guided OAuth flow works step-by-step - āœ… Tests written and passing +- [x] OAuth callback handles success case - āœ… Updated to use InspectorClient +- [x] OAuth callback handles error cases - āœ… Updated to use InspectorClient +- [x] OAuth callback handles guided mode (new tab scenario) - āœ… Shows code for manual copying +- [x] OAuth callback handles guided mode (same tab scenario) - āœ… Sets code without auto-completing +- [x] Single redirect URL approach works - āœ… State parameter distinguishes modes +- [x] Manual code entry in OAuthFlowProgress - āœ… Tests written and passing +- [x] OAuth tokens persist across page reloads - āœ… Handled by BrowserOAuthStorage +- [x] OAuth state updates correctly via events - āœ… Tests written and passing +- [x] Clear OAuth functionality works - āœ… Tests written and passing +- [x] No auto-opening of authorization URL in guided flow - āœ… Test updated +- [ ] OAuth works with both SSE and streamable-http transports - āøļø Needs integration testing + +**Key Implementation Details Discovered:** + +1. **Single Redirect URL with State Parameter:** + - Uses `/oauth/callback` for both normal and guided modes + - Mode encoded in OAuth `state` parameter: `"guided:{random}"` or `"normal:{random}"` + - Matches TUI implementation pattern + - Eliminates need for separate debug endpoint + +2. **Guided Mode Callback Handling:** + - **New Tab Scenario:** Shows authorization code for manual copying, no redirect + - **Same Tab Scenario:** Sets code via `setGuidedAuthorizationCode(code, false)` without auto-completing + - Message: "Please copy this authorization code and return to the Guided Auth flow:" + - Code stored in `sessionStorage` for potential auto-fill (future enhancement) + +3. **InspectorClient Creation Strategy:** + - Lazy creation via `ensureInspectorClient()` helper + - Validates API token before creating client + - Shows toast error if API token missing + - Client created on-demand when needed (connect or auth operations) + - Prevents creating client without API token (would fail API calls) + +4. **Manual Code Entry:** + - Input field added to `OAuthFlowProgress` at `authorization_code` step + - Synchronized with `InspectorClient` state via `setGuidedAuthorizationCode()` + - Matches TUI UX pattern for guided flow + +5. **No Auto-Opening of Authorization URL:** + - Removed auto-opening logic from `proceedToNextStep()` + - Manual button (external link icon) available at `authorization_redirect` step + - Users manually copy code in guided flow (no auto-redirect) + +6. **Remote Logging:** + - Uses `InspectorClient.logger` for persistent logging through redirects + - Logs written to server via `/api/mcp/log` endpoint + - Helps debug OAuth flow across page redirects + +7. **Callback Routing:** + - Handles both `/oauth/callback` and `/` paths (some auth servers redirect incorrectly) + - Checks for OAuth callback parameters in URL search string + - Early return for guided mode without client to avoid unnecessary API calls + +**Functionality Comparison:** + +**Old Implementation Features:** + +- āœ… Explicit error message when serverUrl missing - **Now:** InspectorClient throws error if OAuth not configured (handled via toast) +- āœ… Manual sessionStorage management - **Now:** Handled by BrowserOAuthStorage in InspectorClient +- āœ… Explicit scope discovery (`discoverScopes()`) - **Now:** Handled internally by InspectorClient +- āœ… Manual client registration (static vs DCR) - **Now:** Handled internally by InspectorClient +- āœ… Protected resource metadata discovery - **Now:** Handled internally by InspectorClient +- āœ… State persistence before redirect - **Now:** Handled internally by InspectorClient via BrowserOAuthStorage +- āœ… Step-by-step guided flow with manual state updates - **Now:** Handled by InspectorClient's guided auth flow + +**Potential Gaps/Notes:** + +- āš ļø **Server URL validation**: Old code showed explicit error "Please enter a server URL in the sidebar before authenticating". New code relies on InspectorClient being properly configured with OAuth config. If InspectorClient is null or OAuth not configured, error is shown via toast (different UX but same functionality). +- āš ļø **Manual authorization code entry in guided flow**: Old `OAuthFlowProgress` component had an input field for manual authorization code entry during guided flow. New implementation shows the code if received but doesn't have an input field. However: + - Normal flow: Authorization code handled automatically via callback (`completeOAuthFlow()`) + - Debug flow: `OAuthDebugCallback` component still shows code for manual copying + - If manual entry needed: Can call `inspectorClient.completeOAuthFlow(code)` directly, but UI doesn't expose this + - **Status**: Minor UX gap - functionality exists but not exposed in UI. Could add input field to `OAuthFlowProgress` if needed. +- āœ… **All OAuth features preserved**: Static client, DCR, CIMD, scope discovery, resource metadata, token refresh - all handled by InspectorClient internally. + +--- + +## Phase 5: Remove Express Server Dependency āœ… COMPLETE + +**Status:** āœ… Complete - Express proxy server has been completely removed. No Express server code exists in `web/bin/start.js`. The web app uses only Hono server (via Vite middleware in dev, or `bin/server.js` in prod) for all API endpoints and static file serving. + +**Verification:** + +- āœ… No Express imports or references in `web/bin/start.js` +- āœ… No Express imports or references in `web/src/` (except one test mock value) +- āœ… No Express dependencies in `web/package.json` +- āœ… No proxy server spawning code in start scripts +- āœ… `/config` endpoint replaced with HTML template injection + +**Remaining Legacy References:** + +- `web/src/components/__tests__/Sidebar.test.tsx:64` - Test mock has `connectionType: "proxy"` - This is an unused prop in the test mock (not in actual `SidebarProps` interface). Harmless but should be removed for cleanliness. + +### Step 5.1: Update Start Scripts āœ… COMPLETE + +**File:** `web/bin/start.js` + +**Status:** āœ… Complete + +**As-Built:** + +- `startDevClient()` starts only Vite (Hono middleware handles `/api/*` routes) +- `startProdClient()` starts only Hono server (`bin/server.js`) which serves static files + `/api/*` endpoints +- No Express server spawning code exists +- No `startDevServer()` or `startProdServer()` functions exist + +--- + +### Step 5.2: Remove Proxy Configuration āœ… COMPLETE + +**File:** `web/src/utils/configUtils.ts` + +**Status:** āœ… Complete + +**As-Built:** + +- No `getMCPProxyAddress()` function exists +- No proxy auth token handling (uses `MCP_INSPECTOR_API_TOKEN` for remote API auth) +- No proxy server references in code + +--- + +### Step 5.3: Replace `/config` Endpoint with HTML Template Injection āœ… COMPLETE + +**Files:** `web/bin/server.js`, `web/vite.config.ts`, `web/bin/start.js`, `web/src/App.tsx` + +**Status:** āœ… Complete + +**Approach:** Instead of fetching initial configuration from the Express proxy's `/config` endpoint, we inject configuration values directly into the HTML template served by the Hono server (prod) and Vite dev server (dev). This eliminates the dependency on the Express proxy for initial configuration. + +**Implementation:** + +1. **Start Script (`web/bin/start.js`):** + - Passes config values (command, args, transport, serverUrl, envVars) via environment variables (`MCP_INITIAL_COMMAND`, `MCP_INITIAL_ARGS`, `MCP_INITIAL_TRANSPORT`, `MCP_INITIAL_SERVER_URL`, `MCP_ENV_VARS`) to both Vite (dev) and Hono server (prod) + +2. **Hono Server (`web/bin/server.js`):** + - Intercepts requests to `/` (root) + - Reads `index.html` from dist folder + - Builds `initialConfig` object from env vars (includes `defaultEnvironment` from `getDefaultEnvironment()` + `MCP_ENV_VARS`) + - Injects `` before `` + - Returns modified HTML + +3. **Vite Config (`web/vite.config.ts`):** + - Adds middleware to intercept `/` and `/index.html` requests + - Same injection logic as Hono server (reads from `index.html` source, injects config, returns modified HTML) + +4. **App.tsx (`web/src/App.tsx`):** + - Removed `/config` endpoint fetch + - Reads from `window.__INITIAL_CONFIG__` in a `useEffect` (runs once on mount) + - Applies config values to state (env, command, args, transport, serverUrl) + +**Benefits:** + +- No network request needed for initial config (available immediately) +- Removes dependency on Express proxy for config +- Clean URLs (no query params required for config) +- Works in both dev and prod modes +- Values available synchronously before React renders + +**As-Built:** + +- Config injection happens in both dev (Vite middleware) and prod (Hono server route) +- Uses `getDefaultEnvironment()` from SDK to get default env vars (PATH, HOME, USER, etc.) +- Merges with `MCP_ENV_VARS` if provided +- Config object structure matches what `/config` endpoint returned: `{ defaultCommand?, defaultArgs?, defaultTransport?, defaultServerUrl?, defaultEnvironment }` + +--- + +## Phase 6: Testing and Validation āøļø IN PROGRESS + +### Step 6.1: Functional Testing + +Test each feature to ensure parity with `client/`: + +- [x] Connection management (connect/disconnect) - āœ… Basic functionality working +- [x] Transport types (stdio, SSE, streamable-http) - āœ… All transport types supported +- [x] Tools (list, call, test) - āœ… Working via InspectorClient +- [x] Resources (list, read, subscribe) - āœ… Working via InspectorClient +- [x] Prompts (list, get) - āœ… Working via InspectorClient +- [x] OAuth flows (static client, DCR, CIMD) - āœ… Integrated via InspectorClient (Phase 4 complete) +- [x] Custom headers - āœ… Supported in config adapter +- [x] Request history - āœ… Using MCP protocol messages +- [x] Stderr logging - āœ… ConsoleTab displays stderr logs +- [x] Notifications - āœ… Extracted from message stream +- [x] Elicitation requests - āœ… Event listeners working +- [x] Sampling requests - āœ… Event listeners working +- [x] Roots management - āœ… getRoots/setRoots working +- [ ] Progress notifications - āøļø Needs validation + +**Recent Bug Fixes:** + +- āœ… Fixed infinite loop in `useInspectorClient` hook (messages/stderrLogs/fetchRequests) - root cause: `InspectorClient.getMessages()` returns new array references. Fixed by comparing serialized content before updating state. +- āœ… Fixed infinite loop in `App.tsx` notifications extraction - fixed by using `useMemo` + `useRef` with content comparison +- āœ… Removed debug `console.log` statements from `App.tsx` +- āœ… Added console output capture in tests (schemaUtils, auth tests) to validate expected warnings/debug messages + +--- + +### Step 6.2: Integration Testing + +- [ ] Dev mode: Vite + Hono middleware works +- [ ] Prod mode: Hono server serves static files and API +- [ ] Same-origin requests (no CORS issues) +- [ ] Auth token handling +- [ ] Storage persistence +- [ ] Error handling + +--- + +## Phase 7: Cleanup + +### Step 7.1: Remove Unused Code + +- [x] Delete `useConnection.ts` hook - āœ… Already removed (no files found) +- [x] Remove Express server references - āœ… Express proxy completely removed (no Express code exists) +- [x] Remove proxy-related utilities - āœ… No proxy utilities found in codebase +- [x] Clean up unused imports - āœ… Basic cleanup done (console.log removed) +- [x] Remove unused test prop - āœ… Removed `connectionType` and `setConnectionType` from `Sidebar.test.tsx` mock + +--- + +### Step 7.2: Update Documentation + +- [ ] Update README with new architecture +- [ ] Document Hono integration +- [ ] Update development setup instructions + +--- + +## Implementation Order + +**Recommended order:** + +1. **Phase 1** (Hono Integration) - Foundation for everything else +2. **Phase 2** (Adapters) - Needed before Phase 3 +3. **Phase 3** (InspectorClient Integration) - Core functionality +4. **Phase 4** (OAuth) - Can be done in parallel with Phase 3 +5. **Phase 5** (Remove Express) - After everything works +6. **Phase 6** (Testing) - Throughout, but comprehensive at end (functional and integration testing only) +7. **Phase 7** (Cleanup) - Final step + +--- + +## Key Differences from Current Client + +| Aspect | Current Client | New Web App | +| -------------- | ----------------------------- | ---------------------------------------------- | +| **Transport** | Direct SDK transports + proxy | Remote transport via Hono API | +| **Server** | Separate Express server | Hono middleware in Vite | +| **OAuth** | Custom state machine | InspectorClient OAuth methods | +| **State** | Custom formats | InspectorClient formats (MessageEntry[], etc.) | +| **Connection** | useConnection hook | InspectorClient + useInspectorClient | +| **Fetch** | Direct fetch | createRemoteFetch (for OAuth) | +| **Logging** | Console only | createRemoteLogger | + +--- + +## Success Criteria + +The port is complete when: + +1. āœ… All features from `client/` work identically in `web/` +2. āœ… No separate Express server required +3. āœ… Same-origin requests (no CORS) +4. āœ… OAuth flows work (static, DCR, CIMD) +5. āœ… All transport types work (stdio, SSE, streamable-http) +6. āœ… Request history, stderr logs, notifications all work +7. āœ… Code is cleaner and more maintainable + +--- + +## Notes + +- Keep `client/` unchanged during port (it's the reference implementation) +- Test incrementally - don't try to port everything at once +- Use feature flags if needed to test new code alongside old code +- The web app can be deleted from the PR after POC is complete (if not merging) + +--- + +## Issues + +Future: Extend useInspectorClient to expose OAuth state (for guided flow in web and TUI) + +node cli/build/cli.js --web --config mcp.json --server hosted-everything diff --git a/client/eslint.config.js b/eslint.config.js similarity index 68% rename from client/eslint.config.js rename to eslint.config.js index 79a552ea9..7873c99d5 100644 --- a/client/eslint.config.js +++ b/eslint.config.js @@ -5,13 +5,13 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { ignores: ["**/dist", "**/build", "**/node_modules"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, - globals: globals.browser, + globals: { ...globals.browser, ...globals.node }, }, plugins: { "react-hooks": reactHooks, @@ -23,6 +23,11 @@ export default tseslint.config( "warn", { allowConstantExport: true }, ], + // Args named with leading _ are intentionally unused (e.g. interface-required params). + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_" }, + ], }, }, ); diff --git a/package-lock.json b/package-lock.json index 6b4dbc29b..269c77f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,18 @@ "version": "0.20.0", "license": "SEE LICENSE IN LICENSE", "workspaces": [ - "client", - "server", - "cli" + "web", + "cli", + "tui", + "core", + "test-servers" ], "dependencies": { "@modelcontextprotocol/inspector-cli": "^0.20.0", - "@modelcontextprotocol/inspector-client": "^0.20.0", - "@modelcontextprotocol/inspector-server": "^0.20.0", + "@modelcontextprotocol/inspector-web": "^0.20.0", "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", + "hono": "^4.11.7", "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", @@ -30,17 +32,24 @@ "mcp-inspector": "cli/build/cli.js" }, "devDependencies": { + "@eslint/js": "^9.11.1", "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", "@types/node": "^22.17.0", "@types/shell-quote": "^1.7.5", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", "husky": "^9.1.7", "jest-fixed-jsdom": "^0.0.9", "lint-staged": "^16.1.5", "playwright": "^1.56.1", "prettier": "^3.7.1", "rimraf": "^6.0.1", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "typescript-eslint": "^8.38.0", + "vitest": "^4.0.17" }, "engines": { "node": ">=22.7.5" @@ -49,122 +58,51 @@ "cli": { "name": "@modelcontextprotocol/inspector-cli", "version": "0.20.0", - "license": "SEE LICENSE IN LICENSE", + "license": "MIT", "dependencies": { + "@modelcontextprotocol/inspector-core": "*", "@modelcontextprotocol/sdk": "^1.25.2", "commander": "^13.1.0", - "express": "^5.2.1", + "express": "^5.1.0", "spawn-rx": "^5.1.2" }, "bin": { "mcp-inspector-cli": "build/cli.js" }, "devDependencies": { + "@modelcontextprotocol/inspector-test-server": "*", "@types/express": "^5.0.0", "tsx": "^4.7.0", "vitest": "^4.0.17" } }, - "cli/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "client": { - "name": "@modelcontextprotocol/inspector-client", + "core": { + "name": "@modelcontextprotocol/inspector-core", "version": "0.20.0", - "license": "SEE LICENSE IN LICENSE", "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", - "@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", - "clsx": "^2.1.1", - "cmdk": "^1.0.4", - "lucide-react": "^0.523.0", - "pkce-challenge": "^4.1.0", - "prismjs": "^1.30.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-simple-code-editor": "^0.14.1", - "serve-handler": "^6.1.6", - "tailwind-merge": "^2.5.3", - "zod": "^3.25.76" - }, - "bin": { - "mcp-inspector-client": "bin/start.js" + "atomically": "^2.1.1", + "hono": "^4.6.0", + "zustand": "^5.0.10" }, "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", + "@hono/node-server": "^1.19.0", + "@modelcontextprotocol/inspector-test-server": "*", + "@modelcontextprotocol/sdk": "^1.25.2", + "@testing-library/react": "^16.0.0", + "@types/express": "^5.0.0", "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.0", - "@types/serve-handler": "^6.1.4", - "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.20", - "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", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.13", - "tailwindcss-animate": "^1.0.7", - "ts-jest": "^29.4.0", - "typescript": "^5.5.3", - "typescript-eslint": "^8.38.0", - "vite": "^7.1.11" - } - }, - "client/node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "express": "^5.1.0", + "jsdom": "^25.0.0", + "pino": "^9.6.0", + "react": "^18.3.1", + "typescript": "^5.4.2", + "vitest": "^4.0.17", + "zod": "^3.25 || ^4.0" }, "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "@modelcontextprotocol/sdk": "^1.25.2", + "pino": "^9.6.0", + "react": "^18.3.1" } }, "node_modules/@adobe/css-tools": { @@ -174,6 +112,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -193,7 +156,6 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -207,17 +169,16 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -226,9 +187,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -236,21 +197,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -267,14 +228,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -284,13 +245,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -311,29 +272,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -343,9 +304,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -383,27 +344,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -412,53 +373,30 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.27.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -467,375 +405,149 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@jridgewell/trace-mapping": "0.3.9" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=12" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "engines": { + "node": ">=18" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { "node": ">=18" @@ -861,7 +573,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -885,15 +596,14 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -908,9 +618,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -925,9 +635,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -942,9 +652,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -959,9 +669,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -976,9 +686,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -993,9 +703,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -1010,9 +720,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -1027,9 +737,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -1044,9 +754,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -1061,9 +771,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -1078,9 +788,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -1095,9 +805,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1112,9 +822,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1129,9 +839,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1146,9 +856,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1163,9 +873,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1180,9 +890,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1197,9 +907,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1214,9 +924,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1231,9 +941,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1248,9 +958,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -1265,9 +975,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1282,9 +992,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1299,9 +1009,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1316,9 +1026,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1333,9 +1043,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1416,20 +1126,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1439,6 +1149,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1452,10 +1179,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1490,31 +1224,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1591,218 +1325,141 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, "engines": { - "node": "20 || >=22" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@isaacs/balanced-match": "^4.0.1" + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, + "peer": true, "engines": { "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "p-locate": "^4.1.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, + "peer": true, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@jest/console": { + "node_modules/@jest/expect-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" + "jest-get-type": "^29.6.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/ansi-styles": { + "node_modules/@jest/fake-timers/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1810,110 +1467,118 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/@jest/fake-timers/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, + "peer": true, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@jest/fake-timers/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract": { + "node_modules/@jest/fake-timers/node_modules/jest-util": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", - "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", - "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" }, - "peerDependencies": { - "canvas": "^3.0.0", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "node_modules/@jest/fake-timers/node_modules/pretty-format": { "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "node_modules/@jest/fake-timers/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-regex-util": "30.0.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "node_modules/@jest/schemas": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", @@ -1927,7 +1592,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "node_modules/@jest/types": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", @@ -1947,5936 +1612,3385 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@types/jsdom": { - "version": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", - "peer": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "dev": true, - "license": "MIT", - "peer": true, + "node_modules/@mcp-ui/client": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-6.1.0.tgz", + "integrity": "sha512-Wk/9uhu8xdOgHjiaEtAq2RbXn4WGstpFeJ6I71JCP7JC7MtvQB/qnEKDVGSbjwyLnIeZYMSILHf5E+57/YCftQ==", + "license": "Apache-2.0", "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "@quilted/threads": "^3.1.3", + "@r2wc/react-to-web-component": "^2.0.4", + "@remote-dom/core": "^1.8.0", + "@remote-dom/react": "^1.2.2", + "zod": "^3.23.8" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "dev": true, + "node_modules/@mcp-ui/client/node_modules/@modelcontextprotocol/ext-apps": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.3.1.tgz", + "integrity": "sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==", + "hasInstallScript": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "dev": true, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.1.2.tgz", + "integrity": "sha512-Gx4TEo3/F8yq1Ix6LdgLwMrKqfZqD7++eakZdbMUewrYtHeeJn3nKpeNhgEfO7nYRwonqWYomOAszWZWJS0IbA==", + "hasInstallScript": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT", - "peer": true + "node_modules/@modelcontextprotocol/inspector-cli": { + "resolved": "cli", + "link": true }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "node_modules/@modelcontextprotocol/inspector-core": { + "resolved": "core", + "link": true }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "node_modules/@modelcontextprotocol/inspector-test-server": { + "resolved": "test-servers", + "link": true }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, + "node_modules/@modelcontextprotocol/inspector-tui": { + "resolved": "tui", + "link": true + }, + "node_modules/@modelcontextprotocol/inspector-web": { + "resolved": "web", + "link": true + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "node": ">=18" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 8" } }, - "node_modules/@jest/pattern/node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 8" } }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">= 8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz", + "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz", + "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz", + "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz", + "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz", + "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz", + "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz", + "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz", + "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz", + "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz", + "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz", + "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" } }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, + "node_modules/@preact/signals-core": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", + "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" } }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, + "node_modules/@quilted/events": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@quilted/events/-/events-2.1.3.tgz", + "integrity": "sha512-4fHaSLND8rmZ+tce9/4FNmG5UWTRpFtM54kOekf3tLON4ZLLnYzjjldELD35efd7+lT5+E3cdkacqc56d+kCrQ==", "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" + "@preact/signals-core": "^1.8.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, + "node_modules/@quilted/threads": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@quilted/threads/-/threads-3.3.1.tgz", + "integrity": "sha512-0ASnjTH+hOu1Qwzi9NnsVcsbMhWVx8pEE8SXIHknqcc/1rXAU0QlKw9ARq0W43FAdzyVeuXeXtZN27ZC0iALKg==", "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "@quilted/events": "^2.1.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "node": ">=14.0.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "peerDependencies": { + "@preact/signals-core": "^1.8.0" + }, + "peerDependenciesMeta": { + "@preact/signals-core": { + "optional": true + } } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } + "node_modules/@r2wc/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@r2wc/core/-/core-1.3.1.tgz", + "integrity": "sha512-x9nMthXsTjqr1alE+boX1Zuzqb6/oFi4wAOdWaWcWKcrwq9M/PATK74c3DFJfRnUOkYWPlsz0e4CsFBXJGStSA==", + "license": "MIT" }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "node_modules/@r2wc/react-to-web-component": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@r2wc/react-to-web-component/-/react-to-web-component-2.1.1.tgz", + "integrity": "sha512-AXsIdjxK9ALv0ySWjVadUg3uJ5nS8L4H8eZMYaaWuM+9LNj9DP/r4+sMjI+6jZwb7/FqwxRPHQUq8yimQwBfOA==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@r2wc/core": "^1.3.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@mcp-ui/client": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-6.0.0.tgz", - "integrity": "sha512-dHIQGjFOoBWBntSRUJH5YFeq7xi2rEPS0EwokeNAnMg6xrjGjvNd6vTWDHFRC04OlO/ogvM1r5+xUoo0OETaaQ==", - "license": "Apache-2.0", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^0.3.1", - "@modelcontextprotocol/sdk": "^1.24.0", - "@quilted/threads": "^3.1.3", - "@r2wc/react-to-web-component": "^2.0.4", - "@remote-dom/core": "^1.8.0", - "@remote-dom/react": "^1.2.2", - "zod": "^3.23.8" - }, - "peerDependencies": { - "react": "^18 || ^19", - "react-dom": "^18 || ^19" - } - }, - "node_modules/@mcp-ui/client/node_modules/@modelcontextprotocol/ext-apps": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.3.1.tgz", - "integrity": "sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==", - "hasInstallScript": true, - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "react": { + "@types/react": { "optional": true }, - "react-dom": { + "@types/react-dom": { "optional": true } } }, - "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.0.tgz", - "integrity": "sha512-d5vGKBhWkRoa3xlKOynF8kd+sTSY2D3QSjTMCs46/ddOUOSn5e/E0SaShixFm7H7mNlj4uY0RuU0jAsPM/0qwA==", - "hasInstallScript": true, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", "license": "MIT", - "workspaces": [ - "examples/*" - ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "react": { + "@types/react": { "optional": true }, - "react-dom": { + "@types/react-dom": { "optional": true } } }, - "node_modules/@modelcontextprotocol/inspector-cli": { - "resolved": "cli", - "link": true - }, - "node_modules/@modelcontextprotocol/inspector-client": { - "resolved": "client", - "link": true - }, - "node_modules/@modelcontextprotocol/inspector-server": { - "resolved": "server", - "link": true - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "@cfworker/json-schema": { + "@types/react": { "optional": true }, - "zod": { - "optional": false + "@types/react-dom": { + "optional": true } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@radix-ui/react-compose-refs": "1.1.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz", - "integrity": "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.6.tgz", - "integrity": "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.6.tgz", - "integrity": "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.6.tgz", - "integrity": "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.6.tgz", - "integrity": "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.6.tgz", - "integrity": "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.6.tgz", - "integrity": "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.6.tgz", - "integrity": "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.6.tgz", - "integrity": "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.6.tgz", - "integrity": "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.6.tgz", - "integrity": "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@preact/signals-core": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.2.tgz", - "integrity": "sha512-5Yf8h1Ke3SMHr15xl630KtwPTW4sYDFkkxS0vQ8UiQLWwZQnrF9IKaVG1mN5VcJz52EcWs2acsc/Npjha/7ysA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/@quilted/events": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@quilted/events/-/events-2.1.3.tgz", - "integrity": "sha512-4fHaSLND8rmZ+tce9/4FNmG5UWTRpFtM54kOekf3tLON4ZLLnYzjjldELD35efd7+lT5+E3cdkacqc56d+kCrQ==", - "license": "MIT", - "dependencies": { - "@preact/signals-core": "^1.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@quilted/threads": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@quilted/threads/-/threads-3.3.1.tgz", - "integrity": "sha512-0ASnjTH+hOu1Qwzi9NnsVcsbMhWVx8pEE8SXIHknqcc/1rXAU0QlKw9ARq0W43FAdzyVeuXeXtZN27ZC0iALKg==", - "license": "MIT", - "dependencies": { - "@quilted/events": "^2.1.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "@preact/signals-core": "^1.8.0" - }, - "peerDependenciesMeta": { - "@preact/signals-core": { - "optional": true - } - } - }, - "node_modules/@r2wc/core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@r2wc/core/-/core-1.3.0.tgz", - "integrity": "sha512-aPBnND92Itl+SWWbWyyxdFFF0+RqKB6dptGHEdiPB8ZvnHWHlVzOfEvbEcyUYGtB6HBdsfkVuBiaGYyBFVTzVQ==", - "license": "MIT" - }, - "node_modules/@r2wc/react-to-web-component": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@r2wc/react-to-web-component/-/react-to-web-component-2.1.0.tgz", - "integrity": "sha512-m/PzgUOEiL1HxmvfP5LgBLqB7sHeRj+d1QAeZklwS4OEI2HUU+xTpT3hhJipH5DQoFInDqDTfe0lNFFKcrqk4w==", - "license": "MIT", - "dependencies": { - "@r2wc/core": "^1.3.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-icons": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", - "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", - "license": "MIT", - "peerDependencies": { - "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "license": "MIT" - }, - "node_modules/@remote-dom/core": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@remote-dom/core/-/core-1.10.1.tgz", - "integrity": "sha512-MlbUOGuHjOrB7uOkaYkIoLUG8lDK8/H1D7MHnGkgqbG6jwjwQSlGPHhbwnD6HYWsTGpAPOP02Byd8wBt9U6TEw==", - "license": "MIT", - "dependencies": { - "@remote-dom/polyfill": "^1.5.1", - "htm": "^3.1.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "@preact/signals-core": "^1.3.0" - }, - "peerDependenciesMeta": { - "@preact/signals-core": { - "optional": true - }, - "preact": { - "optional": true - } - } - }, - "node_modules/@remote-dom/polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@remote-dom/polyfill/-/polyfill-1.5.1.tgz", - "integrity": "sha512-eaWdIVKZpNfbqspKkRQLVxiFv/7vIw8u0FVA5oy52YANFbO/WVT0GU+PQmRt/QUSijaB36HBAqx7stjo8HGpVQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@remote-dom/react": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@remote-dom/react/-/react-1.2.2.tgz", - "integrity": "sha512-PkvioODONTr1M0StGDYsR4Ssf5M0Rd4+IlWVvVoK3Zrw8nr7+5mJkgNofaj/z7i8Aep78L28PCW8/WduUt4unA==", - "license": "MIT", - "dependencies": { - "@remote-dom/core": "^1.7.0", - "@types/react": "^18.0.0", - "htm": "^3.1.1" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "license": "MIT" - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "node_modules/@types/jest/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@types/jest/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", - "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-handler": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.4.tgz", - "integrity": "sha512-aXy58tNie0NkuSCY291xUxl0X+kGYy986l4kqW6Gi4kEXgr6Tx0fpSH7YwUSa5usPpG3s9DBeIR6hHcDtL2IvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/shell-quote": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", - "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.49.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", - "dev": true, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", - "dev": true, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", - "dev": true, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "msw": { + "@types/react": { "optional": true }, - "vite": { + "@types/react-dom": { "optional": true } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.17", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">= 6.0.0" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "license": "MIT", "dependencies": { - "ajv": "^8.0.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { - "ajv": "^8.0.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "ajv": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { "optional": true } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@radix-ui/react-primitive": "2.1.4" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "license": "MIT", "dependencies": { - "tslib": "^2.0.0" + "@radix-ui/react-slot": "1.2.4" }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "@types/react-dom": { + "optional": true } - ], + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { - "postcss": "^8.1.0" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=8" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", - "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "@radix-ui/react-compose-refs": "1.1.2" }, - "bin": { - "browserslist": "cli.js" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", "license": "MIT", "dependencies": { - "fast-json-stable-stringify": "2.x" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", "license": "MIT", "dependencies": { - "run-applescript": "^7.0.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + "peerDependenciesMeta": { + "@types/react": { + "optional": true }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "@types/react-dom": { + "optional": true } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@radix-ui/react-compose-refs": "1.1.2" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "license": "MIT", - "engines": { - "node": ">=10" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true } - ], + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "license": "MIT", - "engines": { - "node": ">=8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", "dependencies": { - "clsx": "^2.1.1" + "@radix-ui/rect": "1.1.1" }, - "funding": { - "url": "https://polar.sh/cva" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "license": "MIT", "dependencies": { - "restore-cursor": "^5.0.0" + "@radix-ui/react-use-layout-effect": "1.1.1" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", - "dev": true, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "@radix-ui/react-primitive": "2.1.3" }, - "engines": { - "node": ">=20" + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@remote-dom/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@remote-dom/core/-/core-1.10.1.tgz", + "integrity": "sha512-MlbUOGuHjOrB7uOkaYkIoLUG8lDK8/H1D7MHnGkgqbG6jwjwQSlGPHhbwnD6HYWsTGpAPOP02Byd8wBt9U6TEw==", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@remote-dom/polyfill": "^1.5.1", + "htm": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=14.0.0" + }, + "peerDependencies": { + "@preact/signals-core": "^1.3.0" + }, + "peerDependenciesMeta": { + "@preact/signals-core": { + "optional": true + }, + "preact": { + "optional": true + } } }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/@remote-dom/polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@remote-dom/polyfill/-/polyfill-1.5.1.tgz", + "integrity": "sha512-eaWdIVKZpNfbqspKkRQLVxiFv/7vIw8u0FVA5oy52YANFbO/WVT0GU+PQmRt/QUSijaB36HBAqx7stjo8HGpVQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/@remote-dom/react": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@remote-dom/react/-/react-1.2.2.tgz", + "integrity": "sha512-PkvioODONTr1M0StGDYsR4Ssf5M0Rd4+IlWVvVoK3Zrw8nr7+5mJkgNofaj/z7i8Aep78L28PCW8/WduUt4unA==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@remote-dom/core": "^1.7.0", + "@types/react": "^18.0.0", + "htm": "^3.1.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "node": ">=18.0.0" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/cmdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", - "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-id": "^1.1.0", - "@radix-ui/react-primitive": "^2.0.2" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "license": "MIT" + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } + "peer": true }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "license": "MIT" + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "type-detect": "4.0.8" + } }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" }, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, "peerDependencies": { - "babel-plugin-macros": "^3.1.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "babel-plugin-macros": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { "optional": true } } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT" }, - "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT" }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "peer": true }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.4.0" + "dependencies": { + "@babel/types": "^7.0.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@babel/types": "^7.28.2" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@types/connect": "*", + "@types/node": "*" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "license": "Apache-2.0" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "dependencies": { + "@types/node": "*" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "dependencies": { + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, "engines": { - "node": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" + "undici-types": "~6.21.0" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "@types/prop-types": "*", + "csstype": "^3.2.2" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/@types/shell-quote": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", + "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", + "dev": true, "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "peer": true }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" + "@types/yargs-parser": "*" } }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://eslint.org/donate" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "node": ">= 4" } }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, "license": "MIT", "dependencies": { - "eventsource-parser": "^3.0.1" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": "18 || 20 || >=22" } }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">= 18" + "node": "18 || 20 || >=22" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { - "node": ">= 16" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/express-rate-limit" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "express": ">= 4.11" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">=8.6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 6" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, "license": "MIT", "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": "^12.20 || >= 14.13" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "tinyrainbow": "^3.0.3" }, - "engines": { - "node": ">=16.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/vitest" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" }, - "engines": { - "node": ">=16" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">= 6" + "node": ">= 0.6" } }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">= 0.6" + "node": ">=0.4.0" } }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "license": "MIT", "dependencies": { - "fetch-blob": "^3.1.2" + "acorn": "^8.11.0" }, "engines": { - "node": ">=12.20.0" + "node": ">=0.4.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 14" } }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "engines": { - "node": "*" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/rawify" + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "engines": { - "node": ">= 0.8" + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=8" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 8" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=10" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/arr-rotate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/arr-rotate/-/arr-rotate-1.0.0.tgz", + "integrity": "sha512-yOzOZcR9Tn7enTF66bqKorGGH0F36vcPaSWg8fO0c0UYb3LX3VMXj5ZxEqQLNOecAhlRJ7wYZja5i4jTlnbIfQ==", "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", "engines": { "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", "license": "MIT", "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "bin": { + "autoprefixer": "bin/autoprefixer" }, "engines": { - "node": "*" + "node": "^10 || ^12 || >=14" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=6.0.0" } }, - "node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, - "engines": { - "node": ">= 0.4" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "run-applescript": "^7.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { - "node": ">=16.9.0" + "node": ">= 0.8" } }, - "node_modules/htm": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", - "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", - "license": "Apache-2.0" - }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=12" + "node": ">= 0.4" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, "engines": { - "node": ">= 6" + "node": ">=6" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, "engines": { "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "bin": { - "husky": "bin.js" - }, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">=8" + "node": ">= 8.10.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "license": "ISC", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "is-glob": "^4.0.1" + }, "engines": { - "node": ">= 0.10" + "node": ">= 6" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "clsx": "^2.1.1" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://polar.sh/cva" } }, - "node_modules/is-docker": { + "node_modules/cli-boxes": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" }, "engines": { "node": ">=18" @@ -7885,5245 +4999,5302 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" + "node": ">=12" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", "license": "MIT", "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" }, "engines": { - "node": ">=14.16" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=12" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { - "is-inside-container": "^1.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=16" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" }, - "engines": { - "node": ">=10" + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "convert-to-spaces": "^2.0.1" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "color-name": "~1.1.4" }, "engines": { - "node": ">=10" + "node": ">=7.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "delayed-stream": "~1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { - "jest": "bin/jest.js" + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=18" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "object-assign": "^4", + "vary": "^1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">= 0.10" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">= 8" } }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "bin": { + "cssesc": "bin/cssesc" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=4" } }, - "node_modules/jest-config/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" } }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 12" } }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=18" } }, - "node_modules/jest-diff/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "ms": "^2.1.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-each/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.4.0" } }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-environment-jsdom": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", - "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", - "dev": true, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "peer": true, - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/environment-jsdom-abstract": "30.2.0", - "@types/jsdom": "^21.1.7", - "@types/node": "*", - "jsdom": "^26.1.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": ">= 0.8" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=0.3.1" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "license": "MIT" }, - "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/@types/jsdom": { - "version": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" + "engines": { + "node": ">= 0.8" } }, - "node_modules/jest-environment-jsdom/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 14" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "license": "MIT", - "peer": true, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-environment-jsdom/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, "engines": { - "node": ">=18" + "node": ">= 0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "peer": true, "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" + "es-errors": "^1.3.0" }, "engines": { - "node": ">=18" + "node": ">= 0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "whatwg-encoding": "^3.1.1" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">=18" + "node": ">= 0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">= 14" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" + "node": ">=6" } }, - "node_modules/jest-environment-jsdom/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/jest-environment-jsdom/node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "peerDependencies": { + "eslint": ">=8.40" } }, - "node_modules/jest-environment-jsdom/node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-2-Clause", "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/jest-environment-jsdom/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/eslint" } }, - "node_modules/jest-environment-jsdom/node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/jest-environment-jsdom/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "license": "BSD-2-Clause", "dependencies": { - "tldts": "^6.1.32" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": ">=16" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/jest-environment-jsdom/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-3-Clause", "dependencies": { - "punycode": "^2.3.1" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=0.10" } }, - "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-2-Clause", "dependencies": { - "xml-name-validator": "^5.0.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=18" + "node": ">=4.0" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" + "@types/estree": "^1.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", - "peer": true, "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "eventsource-parser": "^3.0.1" }, "engines": { - "node": ">=18" + "node": ">=18.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18.0.0" } }, - "node_modules/jest-environment-node": { + "node_modules/expect": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-fixed-jsdom": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz", - "integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, "engines": { - "node": ">=18.0.0" + "node": ">= 18" }, - "peerDependencies": { - "jest-environment-jsdom": ">=28.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "node": ">=8.6.0" } }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "is-glob": "^4.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 6" } }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "license": "MIT" }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^12.20 || >= 14.13" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, + "node_modules/figures": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", + "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "escape-string-regexp": "^5.0.0", + "is-unicode-supported": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=16.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "to-regex-range": "^5.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, "engines": { - "node": ">=10" + "node": ">= 18.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=16" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "engines": { + "node": ">= 6" } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "mime-db": "1.52.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "fetch-blob": "^3.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12.20.0" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.8" } }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" - }, + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "is-glob": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10.13.0" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" + "engines": { + "node": ">=18" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" + "engines": { + "node": ">=8" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "has-symbols": "^1.0.3" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": ">= 0.4" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=16.9.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "node_modules/htm": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", + "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", + "license": "Apache-2.0" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, "engines": { - "node": ">=6" + "node": ">= 14" } }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, "engines": { - "node": ">=6" + "node": ">= 14" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "bin": { + "husky": "bin.js" }, "engines": { - "node": ">= 0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=14" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.2", - "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.8.1" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">=20.17" + "node": ">=6" }, "funding": { - "url": "https://opencollective.com/lint-staged" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, "engines": { - "node": ">=20.0.0" + "node": ">=0.8.19" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, + "node_modules/ink": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", + "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", "license": "MIT", "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" }, "engines": { "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } } }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "dev": true, + "node_modules/ink-form": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.1.tgz", + "integrity": "sha512-vo0VMwHf+HOOJo7026K4vJEN8xm4sP9iWlQLx4bngNEEY5K8t30CUvVjQCCNAV6Mt2ODt2Aq+2crCuBONReJUg==", "license": "MIT", "dependencies": { - "environment": "^1.0.0" + "ink-select-input": "^5.0.0", + "ink-text-input": "^6.0.0" + }, + "peerDependencies": { + "ink": ">=4", + "react": ">=18" + } + }, + "node_modules/ink-scroll-view": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/ink-scroll-view/-/ink-scroll-view-0.3.6.tgz", + "integrity": "sha512-XqLkmFFCA5Ow2lVyvv7q0aVHzB78XojIM8gZMQ3//lvy8ZDpJsXWtTRwsCEsdQHnwvMCDCnKCzgF4xkkzoN8mQ==", + "license": "MIT", + "peerDependencies": { + "ink": "^5 || ^6", + "react": "^18 || ^19" + } + }, + "node_modules/ink-select-input": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-5.0.0.tgz", + "integrity": "sha512-VkLEogN3KTgAc0W/u9xK3+44x8JyKfmBvPQyvniJ/Hj0ftg9vWa/YecvZirevNv2SAvgoA2GIlTLCQouzgPKDg==", + "license": "MIT", + "dependencies": { + "arr-rotate": "^1.0.0", + "figures": "^5.0.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": "^4.0.0", + "react": "^18.0.0" + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" }, "engines": { "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "ink": ">=5", + "react": ">=18" } }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, + "node_modules/ink-text-input/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { - "node": ">=12" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lucide-react": { - "version": "0.523.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.523.0.tgz", - "integrity": "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "engines": { + "node": ">= 0.10" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { - "semver": "^7.5.3" + "hasown": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", "bin": { - "semver": "bin/semver.js" + "is-docker": "cli.js" }, "engines": { - "node": ">=10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=8.6" + "node": ">=0.10.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=14.16" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.12.0" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, "engines": { - "node": ">=4" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "*" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/jest-diff/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "node_modules/jest-diff/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" + }, "engines": { - "node": ">=20.17" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/jest-environment-jsdom/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "node_modules/jest-fixed-jsdom": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz", + "integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18.0.0" + }, + "peerDependencies": { + "jest-environment-jsdom": ">=28.0.0" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "dev": true, - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], "license": "MIT", "engines": { - "node": ">=10.5.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, "license": "MIT", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, "engines": { - "node": ">=0.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.0.0" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, "engines": { - "node": ">=0.10.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "node_modules/jest-message-util/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, "engines": { - "node": ">= 6" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/jest-message-util/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "node_modules/jest-message-util/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/jest-mock/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">=8" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/jest-mock/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "yocto-queue": "^0.1.0" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/jest-mock/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, + "peer": true, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dev": true, "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": ">=6" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/jest-util/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "node_modules/jest-util/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", "engines": { - "node": ">= 0.8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/jest-util/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "bin": { + "jiti": "bin/jiti.js" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/panva" } }, - "node_modules/path-is-inside": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", - "license": "(WTFPL OR MIT)" + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": "20 || >=22" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { - "node": "20 || >=22" + "node": ">=6" } }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "license": "ISC" + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=0.10" + "node": ">= 0.8.0" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.0.tgz", + "integrity": "sha512-YVHHy/p6U4/No9Af+35JLh3umJ9dPQnGTvNCbfO/T5fC60us0jFnc+vw33cqveI+kqxIFJQakcMVTO2KM+653A==", "dev": true, "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.2", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, "engines": { - "node": ">= 6" + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" } }, - "node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">=20" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "find-up": "^4.0.0" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" + "node": ">=20.0.0" } }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/listr2/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "p-locate": "^5.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "playwright-core": "1.57.0" - }, - "bin": { - "playwright": "cli.js" + "restore-cursor": "^5.0.0" }, "engines": { "node": ">=18" }, - "optionalDependencies": { - "fsevents": "2.3.2" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "js-tokens": "^3.0.0 || ^4.0.0" }, - "engines": { - "node": "^10 || ^12 || >=14" + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.523.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.523.0.tgz", + "integrity": "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" + "node": ">= 0.4" } }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" + "node": ">= 0.8" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "node": ">=18" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": ">= 8" } }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=4" + "node": ">=8.6" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">= 0.6" } }, - "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "mime-db": "^1.54.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=6" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">= 6" + "node": "*" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 0.10" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" } }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/jimmywarting" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://paypal.me/jimmywarting" } ], - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=10.5.0" } }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": ">= 0.10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">= 6" } }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/react-simple-code-editor": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", - "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "engines": { + "node": ">=14.0.0" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" + "ee-first": "1.1.1" }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">= 0.8" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=8.10.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", "dependencies": { - "resolve": "^1.1.6" + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" }, "engines": { - "node": ">= 0.10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0" }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "callsites": "^3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "resolve-from": "^5.0.0" + "entities": "^6.0.0" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", "license": "MIT", "engines": { - "node": ">=4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" - }, + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, "license": "MIT", "engines": { - "iojs": ">=1.0.0", "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", "dependencies": { - "glob": "^13.0.0", - "package-json-from-dist": "^1.0.1" + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" }, "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, + "license": "MIT", "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16.20.0" } }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@types/estree": "1.0.8" + "playwright-core": "1.58.2" }, "bin": { - "rollup": "dist/bin/rollup" + "playwright": "cli.js" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=18" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" + "fsevents": "2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" }, "engines": { - "node": ">=v12.22.7" + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0" + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "lilconfig": "^3.1.1" }, "engines": { "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "bytes": "3.0.0", - "content-disposition": "0.5.2", - "mime-types": "2.1.18", - "minimatch": "3.1.2", - "path-is-inside": "1.0.2", - "path-to-regexp": "3.3.0", - "range-parser": "1.2.0" - } - }, - "node_modules/serve-handler/node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-handler/node_modules/content-disposition": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", - "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-handler/node_modules/mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "license": "MIT", + "postcss-selector-parser": "^6.1.1" + }, "engines": { - "node": ">= 0.6" + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" } }, - "node_modules/serve-handler/node_modules/mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { - "mime-db": "~1.33.0" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">= 0.6" + "node": ">=4" } }, - "node_modules/serve-handler/node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, - "node_modules/serve-handler/node_modules/range-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", - "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8.0" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">= 18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "shebang-regex": "^3.0.0" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">=8" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "license": "BSD-3-Clause", + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", "dependencies": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" }, "engines": { - "node": ">=4" + "node": ">= 0.10" } }, - "node_modules/shx": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", - "integrity": "sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "dependencies": { - "minimist": "^1.2.3", - "shelljs": "^0.8.5" - }, - "bin": { - "shx": "lib/cli.js" - }, "engines": { "node": ">=6" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" + "side-channel": "^1.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.6" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.10" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "loose-envify": "^1.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, "engines": { - "node": ">=8" + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" } }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { - "node": ">=18" + "node": ">=10" }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, "engines": { - "node": ">=12" + "node": ">=10" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "node_modules/react-simple-code-editor": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", + "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", "dev": true, "license": "MIT", "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "pify": "^2.3.0" } }, - "node_modules/spawn-rx": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/spawn-rx/-/spawn-rx-5.1.2.tgz", - "integrity": "sha512-/y7tJKALVZ1lPzeZZB9jYnmtrL7d0N2zkorii5a7r7dhHkWIuLTzZpZzMJLK1dmYRgX/NCc4iarTO3F7BS2c/A==", + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.7", - "rxjs": "^7.8.1" + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { - "escape-string-regexp": "^2.0.0" + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/redent/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/statuses": { + "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, "engines": { - "node": ">=0.6.19" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" }, "engines": { - "node": ">=20" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=12" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">=8" + "node": ">= 18" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "queue-microtask": "^1.2.2" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" + "tslib": "^2.1.0" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" + "xmlchars": "^2.2.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=v12.22.7" } }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "engines": { - "node": ">= 6" + "dependencies": { + "loose-envify": "^1.1.0" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">=8" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, "engines": { - "node": ">= 0.4" + "node": ">= 18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "dev": true, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { - "any-promise": "^1.0.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { - "thenify": ">= 3.1.0 < 4" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=12" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-rx": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/spawn-rx/-/spawn-rx-5.1.2.tgz", + "integrity": "sha512-/y7tJKALVZ1lPzeZZB9jYnmtrL7d0N2zkorii5a7r7dhHkWIuLTzZpZzMJLK1dmYRgX/NCc4iarTO3F7BS2c/A==", "license": "MIT", + "dependencies": { + "debug": "^4.3.7", + "rxjs": "^7.8.1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", "engines": { - "node": ">=14.0.0" + "node": ">= 10.x" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", - "peer": true, "dependencies": { - "tldts-core": "^6.1.86" + "escape-string-regexp": "^2.0.0" }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">=10" } }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "license": "MIT", - "peer": true + "engines": { + "node": ">=8" + } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "BSD-3-Clause" + "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, "engines": { - "node": ">=8.0" + "node": ">=0.6.19" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">=18.12" + "node": ">=12" }, - "peerDependencies": { - "typescript": ">=4.8.4" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "min-indent": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", "license": "MIT", "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } + "stubborn-utils": "^1.0.1" } }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" }, "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", - "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", - "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", - "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", - "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", - "cpu": [ - "x64" - ], + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": ">= 6" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", - "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", - "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", - "cpu": [ - "x64" - ], + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", - "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", - "cpu": [ - "arm64" - ], + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", - "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", - "cpu": [ - "x64" - ], + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", - "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", - "cpu": [ - "arm" - ], + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", - "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", - "cpu": [ - "arm64" - ], + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "any-promise": "^1.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", - "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", - "cpu": [ - "ia32" - ], + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, "engines": { - "node": ">=18" + "node": ">=0.8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", - "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", - "cpu": [ - "loong64" - ], - "dev": true, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "real-require": "^0.2.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", - "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", - "cpu": [ - "mips64el" - ], + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", - "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", - "cpu": [ - "ppc64" - ], + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", - "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", - "cpu": [ - "riscv64" - ], + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", - "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", - "cpu": [ - "s390x" - ], + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", - "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", - "cpu": [ - "x64" - ], + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", - "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", - "cpu": [ - "arm64" - ], + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", - "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", - "cpu": [ - "x64" - ], + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", - "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", - "cpu": [ - "arm64" - ], + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8.0" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", - "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { - "node": ">=18" + "node": ">=0.6" } }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", - "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", - "cpu": [ - "arm64" - ], + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", - "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", - "cpu": [ - "x64" - ], + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "punycode": "^2.3.1" + }, "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", - "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "bin": { + "tree-kill": "cli.js" } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", - "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", - "cpu": [ - "ia32" - ], + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", - "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", - "cpu": [ - "x64" - ], + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, "bin": { - "esbuild": "bin/esbuild" + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=18" + "node": ">=18.0.0" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/type-check": { @@ -13145,18 +10316,18 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -13190,16 +10361,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13209,40 +10380,16 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -13253,9 +10400,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -13292,17 +10439,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -13359,21 +10495,6 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -13384,13 +10505,13 @@ } }, "node_modules/vite": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", - "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -13476,6 +10597,21 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -13490,19 +10626,19 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -13530,10 +10666,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -13581,26 +10717,16 @@ } }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" + "node": ">=18" } }, "node_modules/web-streams-polyfill": { @@ -13623,16 +10749,17 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-encoding/node_modules/iconv-lite": { @@ -13649,29 +10776,35 @@ } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13704,6 +10837,21 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13714,18 +10862,10 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -13739,24 +10879,10 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -13765,71 +10891,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -13863,13 +10934,13 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { @@ -13899,7 +10970,6 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -13938,6 +11008,12 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -13961,6 +11037,18 @@ "node": ">=8" } }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -13983,6 +11071,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -14001,49 +11095,189 @@ "zod": "^3.25 || ^4" } }, - "server": { - "name": "@modelcontextprotocol/inspector-server", + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "test-servers": { + "name": "@modelcontextprotocol/inspector-test-server", "version": "0.20.0", - "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", - "cors": "^2.8.5", "express": "^5.1.0", - "express-rate-limit": "^8.2.1", - "shell-quote": "^1.8.3", - "shx": "^0.3.4", - "spawn-rx": "^5.1.2", - "ws": "^8.18.0", + "yaml": "^2.8.2", "zod": "^3.25.76" }, "bin": { - "mcp-inspector-server": "build/index.js" + "server-composable": "build/server-composable.js" }, "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/shell-quote": "^1.7.5", - "@types/ws": "^8.5.12", - "tsx": "^4.19.0", - "typescript": "^5.6.2" + "@types/node": "^22.17.0", + "typescript": "^5.4.2", + "vitest": "^4.0.17" } }, - "server/node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "tui": { + "name": "@modelcontextprotocol/inspector-tui", + "version": "0.20.0", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "@modelcontextprotocol/inspector-core": "*", + "@modelcontextprotocol/sdk": "^1.25.2", + "commander": "^13.1.0", + "ink": "^5.2.1", + "ink-form": "^2.0.1", + "ink-scroll-view": "^0.3.6", + "open": "^10.2.0", + "pino": "^9.6.0", + "react": "^18.3.1" }, - "engines": { - "node": ">= 16" + "bin": { + "mcp-inspector-tui": "build/tui.js" }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" + "devDependencies": { + "@types/node": "^25.0.3", + "@types/react": "^18.3.23", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.17" + } + }, + "tui/node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "tui/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "web": { + "name": "@modelcontextprotocol/inspector-web", + "version": "0.20.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.0", + "@mcp-ui/client": "^6.0.0", + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/inspector-core": "*", + "@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", + "@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", + "clsx": "^2.1.1", + "cmdk": "^1.0.4", + "lucide-react": "^0.523.0", + "open": "^10.1.0", + "pino": "^9.6.0", + "pkce-challenge": "^4.1.0", + "prismjs": "^1.30.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-simple-code-editor": "^0.14.1", + "tailwind-merge": "^2.5.3", + "zod": "^3.25.76" }, - "peerDependencies": { - "express": ">= 4.11" + "bin": { + "mcp-inspector-web": "bin/start.js" + }, + "devDependencies": { + "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/node": "^22.17.0", + "@types/prismjs": "^1.26.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.20", + "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", + "jsdom": "^25.0.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.5.3", + "typescript-eslint": "^8.38.0", + "vite": "^7.1.11", + "vitest": "^4.0.17" + } + }, + "web/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "web/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "web/node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" } } } diff --git a/package.json b/package.json index 948f361e6..af881f6c4 100644 --- a/package.json +++ b/package.json @@ -11,36 +11,44 @@ "mcp-inspector": "cli/build/cli.js" }, "files": [ - "client/bin", - "client/dist", - "server/build", - "cli/build" + "web/bin", + "web/dist", + "cli/build", + "tui/build" ], "workspaces": [ - "client", - "server", - "cli" + "web", + "cli", + "tui", + "core", + "test-servers" ], "scripts": { - "build": "npm run build-server && npm run build-client && npm run build-cli", - "build-server": "cd server && npm run build", - "build-client": "cd client && npm run build", + "build": "npm run build-core && npm run build-test && npm run build-web && npm run build-cli && npm run build-tui", + "build-core": "cd core && npm run build", + "build-web": "cd web && npm run build", "build-cli": "cd cli && npm run build", - "clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install", - "dev": "node client/bin/start.js --dev", - "dev:windows": "node client/bin/start.js --dev", + "build-tui": "cd tui && npm run build", + "build-test": "cd test-servers && npm run build", + "server-composable": "node test-servers/build/server-composable.js", + "clean": "rimraf ./node_modules ./web/node_modules ./core/node_modules ./cli/node_modules ./tui/node_modules ./test-servers/node_modules ./build ./web/dist ./cli/build ./tui/build ./test-servers/build ./package-lock.json && npm install", + "dev": "node web/bin/start.js --dev", + "dev:windows": "node web/bin/start.js --dev", "dev:sdk": "npm run link:sdk && concurrently \"npm run dev\" \"cd sdk && npm run build:esm:w\"", "link:sdk": "(test -d sdk || ln -sf ${MCP_SDK:-$PWD/../typescript-sdk} sdk) && (cd sdk && npm link && (test -d node_modules || npm i)) && npm link @modelcontextprotocol/sdk", "unlink:sdk": "(cd sdk && npm unlink -g) && rm sdk && npm unlink @modelcontextprotocol/sdk", - "start": "node client/bin/start.js", - "start-server": "cd server && npm run start", - "start-client": "cd client && npm run preview", - "test": "npm run prettier-check && cd client && npm test", + "start": "node web/bin/start.js", + "web": "node cli/build/cli.js --web", + "web:dev": "node cli/build/cli.js --web --dev", + "test": "vitest run", + "test:repeat": "node scripts/test-repeat.js", "test-cli": "cd cli && npm run test", - "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", + "test-core": "cd core && npm run test", + "test-web": "cd web && npm run test", + "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=web", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", - "lint": "prettier --check . && cd client && npm run lint", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "prepare": "husky && npm run build", "publish-all": "npm publish --workspaces --access public && npm publish --access public", "update-version": "node scripts/update-version.js", @@ -48,10 +56,10 @@ }, "dependencies": { "@modelcontextprotocol/inspector-cli": "^0.20.0", - "@modelcontextprotocol/inspector-client": "^0.20.0", - "@modelcontextprotocol/inspector-server": "^0.20.0", + "@modelcontextprotocol/inspector-web": "^0.20.0", "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", + "hono": "^4.11.7", "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", @@ -60,6 +68,12 @@ "zod": "^3.25.76" }, "devDependencies": { + "@eslint/js": "^9.11.1", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "typescript-eslint": "^8.38.0", "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", "@types/node": "^22.17.0", @@ -70,10 +84,12 @@ "playwright": "^1.56.1", "prettier": "^3.7.1", "rimraf": "^6.0.1", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "vitest": "^4.0.17" }, "overrides": { - "get-intrinsic": "1.3.0" + "get-intrinsic": "1.3.0", + "ink": "^5.2.1" }, "engines": { "node": ">=22.7.5" diff --git a/scripts/README.md b/scripts/README.md index d6887faf8..9cc527a95 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,7 +18,7 @@ npm run update-version 0.14.3 This script will: -1. Update the version in all package.json files (root, client, server, cli) +1. Update the version in all package.json files (root, web, core, cli, tui, test) 2. Update workspace dependencies in the root package.json 3. Run `npm install` to update package-lock.json 4. Provide next steps for committing and tagging diff --git a/scripts/check-version-consistency.js b/scripts/check-version-consistency.js index 379931dea..99ef56f7b 100755 --- a/scripts/check-version-consistency.js +++ b/scripts/check-version-consistency.js @@ -18,9 +18,11 @@ console.log("šŸ” Checking version consistency across packages...\n"); // List of package.json files to check const packagePaths = [ "package.json", - "client/package.json", - "server/package.json", + "web/package.json", + "core/package.json", "cli/package.json", + "tui/package.json", + "test-servers/package.json", ]; const versions = new Map(); @@ -132,9 +134,14 @@ if (!fs.existsSync(lockPath)) { // Check workspace package versions in lock file if (lockFile.packages) { const workspacePackages = [ - { path: "client", name: "@modelcontextprotocol/inspector-client" }, - { path: "server", name: "@modelcontextprotocol/inspector-server" }, + { path: "web", name: "@modelcontextprotocol/inspector-web" }, + { path: "core", name: "@modelcontextprotocol/inspector-core" }, { path: "cli", name: "@modelcontextprotocol/inspector-cli" }, + { path: "tui", name: "@modelcontextprotocol/inspector-tui" }, + { + path: "test-servers", + name: "@modelcontextprotocol/inspector-test-server", + }, ]; workspacePackages.forEach(({ path, name }) => { diff --git a/scripts/test-repeat.js b/scripts/test-repeat.js new file mode 100644 index 000000000..5d7273134 --- /dev/null +++ b/scripts/test-repeat.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; + +/** + * Equivalent to npm run test (prettier-check once, then vitest run), but runs + * vitest run in a loop up to N times, stopping on first failure. + * + * Usage: node scripts/test-repeat.js [N] + * N = iteration count (default 5). Example: npm run test:repeat -- 10 + */ + +const args = process.argv.slice(2); +let maxIterations = 5; +if (args.length > 0) { + const n = parseInt(args[0], 10); + if (Number.isNaN(n) || n < 1) { + console.error("Invalid count: expected positive integer"); + process.exit(1); + } + maxIterations = n; +} + +console.log(`Prettier check (once)...`); +execSync("npm run prettier-check", { stdio: "inherit" }); + +console.log( + `\nVitest run: up to ${maxIterations} iteration(s), stopping on first failure.\n`, +); +for (let i = 1; i <= maxIterations; i++) { + console.log(`--- Iteration ${i}/${maxIterations} ---`); + try { + execSync("vitest run", { stdio: "inherit" }); + } catch (err) { + process.exit(err.status ?? 1); + } +} +console.log(`\nAll ${maxIterations} iteration(s) passed.`); diff --git a/scripts/update-version.js b/scripts/update-version.js index 91b69f3bf..136c0c8c0 100755 --- a/scripts/update-version.js +++ b/scripts/update-version.js @@ -37,9 +37,11 @@ console.log(`šŸ”„ Updating all packages to version ${newVersion}...`); // List of package.json files to update const packagePaths = [ "package.json", - "client/package.json", - "server/package.json", + "web/package.json", + "core/package.json", "cli/package.json", + "tui/package.json", + "test-servers/package.json", ]; const updatedFiles = []; diff --git a/server/package.json b/server/package.json deleted file mode 100644 index b072c4ce9..000000000 --- a/server/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@modelcontextprotocol/inspector-server", - "version": "0.20.0", - "description": "Server-side application for the Model Context Protocol inspector", - "license": "SEE LICENSE IN LICENSE", - "author": "Model Context Protocol a Series of LF Projects, LLC.", - "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", - "type": "module", - "bin": { - "mcp-inspector-server": "build/index.js" - }, - "files": [ - "build" - ], - "scripts": { - "build": "tsc && shx cp -R static build", - "start": "node build/index.js", - "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.0", - "@types/shell-quote": "^1.7.5", - "@types/ws": "^8.5.12", - "tsx": "^4.19.0", - "typescript": "^5.6.2" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", - "cors": "^2.8.5", - "express": "^5.1.0", - "shell-quote": "^1.8.3", - "shx": "^0.3.4", - "spawn-rx": "^5.1.2", - "ws": "^8.18.0", - "zod": "^3.25.76", - "express-rate-limit": "^8.2.1" - } -} diff --git a/server/src/index.ts b/server/src/index.ts deleted file mode 100644 index 4d1fffa29..000000000 --- a/server/src/index.ts +++ /dev/null @@ -1,846 +0,0 @@ -#!/usr/bin/env node - -import cors from "cors"; -import { parseArgs } from "node:util"; -import { parse as shellParseArgs } from "shell-quote"; -import nodeFetch, { Headers as NodeHeaders } from "node-fetch"; - -// Type-compatible wrappers for node-fetch to work with browser-style types -const fetch = nodeFetch; -const Headers = NodeHeaders; - -import { - SSEClientTransport, - SseError, -} from "@modelcontextprotocol/sdk/client/sse.js"; -import { - StdioClientTransport, - getDefaultEnvironment, -} from "@modelcontextprotocol/sdk/client/stdio.js"; -import { - StreamableHTTPClientTransport, - StreamableHTTPError, -} from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import express from "express"; -import rateLimit from "express-rate-limit"; -import { findActualExecutable } from "spawn-rx"; -import mcpProxy from "./mcpProxy.js"; -import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; -import { readFileSync } from "fs"; - -const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; - -const sandboxRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 /sandbox requests per windowMs -}); - -const defaultEnvironment = { - ...getDefaultEnvironment(), - ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}), -}; - -const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - env: { type: "string", default: "" }, - args: { type: "string", default: "" }, - command: { type: "string", default: "" }, - transport: { type: "string", default: "" }, - "server-url": { type: "string", default: "" }, - }, -}); - -/** - * Helper function to detect 401 Unauthorized errors from various transport types. - * StreamableHTTPClientTransport throws a generic Error with "HTTP 401" in the message - * when there's no authProvider configured, while SSEClientTransport throws SseError. - */ -const is401Error = (error: unknown): boolean => { - if (error instanceof SseError && error.code === 401) return true; - if (error instanceof StreamableHTTPError && error.code === 401) return true; - if ( - error instanceof Error && - (error.message.includes("HTTP 401") || error.message.includes("(401)")) - ) - return true; - return false; -}; - -// Function to get HTTP headers. -const getHttpHeaders = (req: express.Request): Record => { - const headers: Record = {}; - - // Iterate over all headers in the request - for (const key in req.headers) { - const lowerKey = key.toLowerCase(); - - // Check if the header is one we want to forward - if ( - lowerKey.startsWith("mcp-") || - lowerKey === "authorization" || - lowerKey === "last-event-id" - ) { - // Exclude the proxy's own authentication header and the Client <-> Proxy session ID header - if (lowerKey !== "x-mcp-proxy-auth" && lowerKey !== "mcp-session-id") { - const value = req.headers[key]; - - if (typeof value === "string") { - // If the value is a string, use it directly - headers[key] = value; - } else if (Array.isArray(value)) { - // If the value is an array, use the last element - const lastValue = value.at(-1); - if (lastValue !== undefined) { - headers[key] = lastValue; - } - } - // If value is undefined, it's skipped, which is correct. - } - } - } - - // Handle the custom auth header separately. We expect `x-custom-auth-header` - // to be a string containing the name of the actual authentication header. - const customAuthHeaderName = req.headers["x-custom-auth-header"]; - if (typeof customAuthHeaderName === "string") { - const lowerCaseHeaderName = customAuthHeaderName.toLowerCase(); - const value = req.headers[lowerCaseHeaderName]; - - if (typeof value === "string") { - headers[customAuthHeaderName] = value; - } else if (Array.isArray(value)) { - // If the actual auth header was sent multiple times, use the last value. - const lastValue = value.at(-1); - if (lastValue !== undefined) { - headers[customAuthHeaderName] = lastValue; - } - } - } - - // Handle multiple custom headers (new approach) - if (req.headers["x-custom-auth-headers"] !== undefined) { - try { - const customHeaderNames = JSON.parse( - req.headers["x-custom-auth-headers"] as string, - ) as string[]; - if (Array.isArray(customHeaderNames)) { - customHeaderNames.forEach((headerName) => { - const lowerCaseHeaderName = headerName.toLowerCase(); - if (req.headers[lowerCaseHeaderName] !== undefined) { - const value = req.headers[lowerCaseHeaderName]; - headers[headerName] = Array.isArray(value) - ? value[value.length - 1] - : value; - } - }); - } - } catch (error) { - console.warn("Failed to parse x-custom-auth-headers:", error); - } - } - return headers; -}; - -/** - * Updates a headers object in-place, preserving the original Accept header. - * This is necessary to ensure that transports holding a reference to the headers - * object see the updates. - * @param currentHeaders The headers object to update. - * @param newHeaders The new headers to apply. - */ -const updateHeadersInPlace = ( - currentHeaders: Record, - newHeaders: Record, -) => { - // Preserve the Accept header, which is set at transport creation and - // is not present in subsequent client requests. - const accept = currentHeaders["Accept"]; - - // Clear the old headers and apply the new ones. - Object.keys(currentHeaders).forEach((key) => delete currentHeaders[key]); - Object.assign(currentHeaders, newHeaders); - - // Restore the Accept header. - if (accept) { - currentHeaders["Accept"] = accept; - } -}; - -const app = express(); -app.use(cors()); -app.use((req, res, next) => { - res.header("Access-Control-Expose-Headers", "mcp-session-id"); - next(); -}); - -const webAppTransports: Map = new Map(); // Web app transports by web app sessionId -const serverTransports: Map = new Map(); // Server Transports by web app sessionId -const sessionHeaderHolders: Map = new Map(); // For dynamic header updates - -// 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; - -// Origin validation middleware to prevent DNS rebinding attacks -const originValidationMiddleware = ( - req: express.Request, - res: express.Response, - next: express.NextFunction, -) => { - const origin = req.headers.origin; - - // Default origins based on CLIENT_PORT or use environment variable - const clientPort = process.env.CLIENT_PORT || "6274"; - const defaultOrigin = `http://localhost:${clientPort}`; - const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [ - defaultOrigin, - ]; - - if (origin && !allowedOrigins.includes(origin)) { - console.error(`Invalid origin: ${origin}`); - res.status(403).json({ - error: "Forbidden - invalid origin", - message: - "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.", - }); - return; - } - next(); -}; - -const authMiddleware = ( - req: express.Request, - res: express.Response, - next: express.NextFunction, -) => { - if (authDisabled) { - return next(); - } - - const sendUnauthorized = () => { - res.status(401).json({ - error: "Unauthorized", - message: - "Authentication required. Use the session token shown in the console when starting the server.", - }); - }; - - const authHeader = req.headers["x-mcp-proxy-auth"]; - const authHeaderValue = Array.isArray(authHeader) - ? authHeader[0] - : authHeader; - - if (!authHeaderValue || !authHeaderValue.startsWith("Bearer ")) { - sendUnauthorized(); - return; - } - - const providedToken = authHeaderValue.substring(7); // Remove 'Bearer ' prefix - const expectedToken = sessionToken; - - // Convert to buffers for timing-safe comparison - const providedBuffer = Buffer.from(providedToken); - const expectedBuffer = Buffer.from(expectedToken); - - // Check length first to prevent timing attacks - if (providedBuffer.length !== expectedBuffer.length) { - sendUnauthorized(); - return; - } - - // Perform timing-safe comparison - if (!timingSafeEqual(providedBuffer, expectedBuffer)) { - sendUnauthorized(); - return; - } - - next(); -}; - -/** - * Converts a Node.js ReadableStream to a web-compatible ReadableStream - * This is necessary for the EventSource polyfill which expects web streams - */ -const createWebReadableStream = (nodeStream: any): ReadableStream => { - let closed = false; - return new ReadableStream({ - start(controller) { - nodeStream.on("data", (chunk: any) => { - if (!closed) { - controller.enqueue(chunk); - } - }); - nodeStream.on("end", () => { - if (!closed) { - closed = true; - controller.close(); - } - }); - nodeStream.on("error", (err: any) => { - if (!closed) { - closed = true; - controller.error(err); - } - }); - }, - cancel() { - closed = true; - nodeStream.destroy(); - }, - }); -}; - -/** - * Creates a `fetch` function that merges dynamic session headers with the - * headers from the actual request, ensuring that request-specific headers like - * `Content-Type` are preserved. For SSE requests, it also converts Node.js - * streams to web-compatible streams. - */ -const createCustomFetch = (headerHolder: { headers: HeadersInit }) => { - return async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - // Determine the headers from the original request/init. - // The SDK may pass a Request object or a URL and an init object. - const originalHeaders = - input instanceof Request ? input.headers : init?.headers; - - // Start with our dynamic session headers. - const finalHeaders = new Headers(headerHolder.headers); - - // Merge the SDK's request-specific headers, letting them overwrite. - // This is crucial for preserving Content-Type on POST requests. - new Headers(originalHeaders).forEach((value, key) => { - finalHeaders.set(key, value); - }); - - // Convert Headers to a plain object for node-fetch compatibility - const headersObject: Record = {}; - finalHeaders.forEach((value, key) => { - headersObject[key] = value; - }); - - // Get the response from node-fetch (cast input and init to handle type differences) - const response = await fetch( - input as any, - { ...init, headers: headersObject } as any, - ); - - // Check if this is an SSE request by looking at the Accept header - const acceptHeader = finalHeaders.get("Accept"); - const isSSE = acceptHeader?.includes("text/event-stream"); - - if (isSSE && response.body) { - // For SSE requests, we need to convert the Node.js stream to a web ReadableStream - // because the EventSource polyfill expects web-compatible streams - const webStream = createWebReadableStream(response.body); - - // Create a new response with the web-compatible stream - // Convert node-fetch headers to plain object for web Response compatibility - const responseHeaders: Record = {}; - response.headers.forEach((value: string, key: string) => { - responseHeaders[key] = value; - }); - - return new Response(webStream, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }) as Response; - } - - // For non-SSE requests, return the response as-is (cast to handle type differences) - return response as unknown as Response; - }; -}; - -const createTransport = async ( - req: express.Request, -): Promise<{ - transport: Transport; - headerHolder?: { headers: HeadersInit }; -}> => { - const query = req.query; - console.log("Query parameters:", JSON.stringify(query)); - - const transportType = query.transportType as string; - - if (transportType === "stdio") { - const command = (query.command as string).trim(); - const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; - - const { cmd, args } = findActualExecutable(command, origArgs); - - console.log(`STDIO transport: command=${cmd}, args=${args}`); - - const transport = new StdioClientTransport({ - command: cmd, - args, - env, - stderr: "pipe", - }); - - await transport.start(); - return { transport }; - } else if (transportType === "sse") { - const url = query.url as string; - - const headers = getHttpHeaders(req); - headers["Accept"] = "text/event-stream"; - const headerHolder = { headers }; - - console.log( - `SSE transport: url=${url}, headers=${JSON.stringify(headers)}`, - ); - - const transport = new SSEClientTransport(new URL(url), { - eventSourceInit: { - fetch: createCustomFetch(headerHolder), - }, - requestInit: { - headers: headerHolder.headers, - }, - }); - await transport.start(); - return { transport, headerHolder }; - } else if (transportType === "streamable-http") { - const headers = getHttpHeaders(req); - headers["Accept"] = "text/event-stream, application/json"; - const headerHolder = { headers }; - - const transport = new StreamableHTTPClientTransport( - new URL(query.url as string), - { - // Pass a custom fetch to inject the latest headers on each request - fetch: createCustomFetch(headerHolder), - }, - ); - await transport.start(); - return { transport, headerHolder }; - } else { - console.error(`Invalid transport type: ${transportType}`); - throw new Error("Invalid transport type specified"); - } -}; - -app.get( - "/mcp", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string; - console.log(`Received GET message for sessionId ${sessionId}`); - - const headerHolder = sessionHeaderHolders.get(sessionId); - if (headerHolder) { - updateHeadersInPlace( - headerHolder.headers as Record, - getHttpHeaders(req), - ); - } - - try { - const transport = webAppTransports.get( - sessionId, - ) as StreamableHTTPServerTransport; - if (!transport) { - res.status(404).end("Session not found"); - return; - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } - }, -); - -app.post( - "/mcp", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (sessionId) { - console.log(`Received POST message for sessionId ${sessionId}`); - const headerHolder = sessionHeaderHolders.get(sessionId); - if (headerHolder) { - updateHeadersInPlace( - headerHolder.headers as Record, - getHttpHeaders(req), - ); - } - - try { - const transport = webAppTransports.get( - sessionId, - ) as StreamableHTTPServerTransport; - if (!transport) { - res.status(404).end("Transport not found for sessionId " + sessionId); - } else { - await (transport as StreamableHTTPServerTransport).handleRequest( - req, - res, - ); - } - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } - } else { - console.log("New StreamableHttp connection request"); - try { - const { transport: serverTransport, headerHolder } = - await createTransport(req); - - const webAppTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: randomUUID, - onsessioninitialized: (sessionId) => { - webAppTransports.set(sessionId, webAppTransport); - serverTransports.set(sessionId, serverTransport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (headerHolder) { - sessionHeaderHolders.set(sessionId, headerHolder); - } - console.log("Client <-> Proxy sessionId: " + sessionId); - }, - onsessionclosed: (sessionId) => { - webAppTransports.delete(sessionId); - serverTransports.delete(sessionId); - sessionHeaderHolders.delete(sessionId); - }, - }); - console.log("Created StreamableHttp client transport"); - - await webAppTransport.start(); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - - await (webAppTransport as StreamableHTTPServerTransport).handleRequest( - req, - res, - req.body, - ); - } catch (error) { - if (is401Error(error)) { - console.error( - "Received 401 Unauthorized from MCP server:", - error instanceof Error ? error.message : error, - ); - res.status(401).json(error); - return; - } - console.error("Error in /mcp POST route:", error); - res.status(500).json(error); - } - } - }, -); - -app.delete( - "/mcp", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`Received DELETE message for sessionId ${sessionId}`); - if (sessionId) { - try { - const serverTransport = serverTransports.get( - sessionId, - ) as StreamableHTTPClientTransport; - if (!serverTransport) { - res.status(404).end("Transport not found for sessionId " + sessionId); - } else { - await serverTransport.terminateSession(); - await serverTransport.close(); - webAppTransports.delete(sessionId); - serverTransports.delete(sessionId); - sessionHeaderHolders.delete(sessionId); - console.log(`Transports removed for sessionId ${sessionId}`); - } - res.status(200).end(); - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } - } - }, -); - -app.get( - "/stdio", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - try { - console.log("New STDIO connection request"); - const { transport: serverTransport } = await createTransport(req); - - const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; - const prefix = proxyFullAddress || ""; - const endpoint = `${prefix}/message`; - - const webAppTransport = new SSEServerTransport(endpoint, res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created client transport"); - - serverTransports.set(webAppTransport.sessionId, serverTransport); - console.log("Created server transport"); - - await webAppTransport.start(); - - (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { - if (chunk.toString().includes("MODULE_NOT_FOUND")) { - // Server command not found, remove transports - const message = "Command not found, transports removed"; - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level: "emergency", - logger: "proxy", - data: { - message, - }, - }, - }); - webAppTransport.close(); - serverTransport.close(); - webAppTransports.delete(webAppTransport.sessionId); - serverTransports.delete(webAppTransport.sessionId); - sessionHeaderHolders.delete(webAppTransport.sessionId); - console.error(message); - } else { - // Inspect message and attempt to assign a RFC 5424 Syslog Protocol level - let level; - let message = chunk.toString().trim(); - let ucMsg = chunk.toString().toUpperCase(); - if (ucMsg.includes("DEBUG")) { - level = "debug"; - } else if (ucMsg.includes("INFO")) { - level = "info"; - } else if (ucMsg.includes("NOTICE")) { - level = "notice"; - } else if (ucMsg.includes("WARN")) { - level = "warning"; - } else if (ucMsg.includes("ERROR")) { - level = "error"; - } else if (ucMsg.includes("CRITICAL")) { - level = "critical"; - } else if (ucMsg.includes("ALERT")) { - level = "alert"; - } else if (ucMsg.includes("EMERGENCY")) { - level = "emergency"; - } else if (ucMsg.includes("SIGINT")) { - message = "SIGINT received. Server shutdown."; - level = "emergency"; - } else if (ucMsg.includes("SIGHUP")) { - message = "SIGHUP received. Server shutdown."; - level = "emergency"; - } else if (ucMsg.includes("SIGTERM")) { - message = "SIGTERM received. Server shutdown."; - level = "emergency"; - } else { - level = "info"; - } - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level, - logger: "stdio", - data: { - message, - }, - }, - }); - } - }); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - } catch (error) { - if (is401Error(error)) { - console.error( - "Received 401 Unauthorized from MCP server. Authentication failure.", - ); - res.status(401).json(error); - return; - } - console.error("Error in /stdio route:", error); - res.status(500).json(error); - } - }, -); - -app.get( - "/sse", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - try { - console.log( - "New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp", - ); - const { transport: serverTransport, headerHolder } = - await createTransport(req); - - const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; - const prefix = proxyFullAddress || ""; - const endpoint = `${prefix}/message`; - - const webAppTransport = new SSEServerTransport(endpoint, res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created client transport"); - - serverTransports.set(webAppTransport.sessionId, serverTransport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion - if (headerHolder) { - sessionHeaderHolders.set(webAppTransport.sessionId, headerHolder); - } - console.log("Created server transport"); - - await webAppTransport.start(); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - } catch (error) { - if (is401Error(error)) { - console.error( - "Received 401 Unauthorized from MCP server. Authentication failure.", - ); - res.status(401).json(error); - return; - } else if (error instanceof SseError && error.code === 404) { - console.error( - "Received 404 not found from MCP server. Does the MCP server support SSE?", - ); - res.status(404).json(error); - return; - } else if (JSON.stringify(error).includes("ECONNREFUSED")) { - console.error("Connection refused. Is the MCP server running?"); - res.status(500).json(error); - } - console.error("Error in /sse route:", error); - res.status(500).json(error); - } - }, -); - -app.post( - "/message", - originValidationMiddleware, - authMiddleware, - async (req, res) => { - try { - const sessionId = req.query.sessionId as string; - console.log(`Received POST message for sessionId ${sessionId}`); - - const headerHolder = sessionHeaderHolders.get(sessionId); - if (headerHolder) { - updateHeadersInPlace( - headerHolder.headers as Record, - getHttpHeaders(req), - ); - } - - const transport = webAppTransports.get(sessionId) as SSEServerTransport; - if (!transport) { - res.status(404).end("Session not found"); - return; - } - await transport.handlePostMessage(req, res); - } catch (error) { - console.error("Error in /message route:", error); - res.status(500).json(error); - } - }, -); - -app.get("/health", (req, res) => { - res.json({ - status: "ok", - }); -}); - -app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { - try { - res.json({ - defaultEnvironment, - defaultCommand: values.command, - defaultArgs: values.args, - defaultTransport: values.transport, - defaultServerUrl: values["server-url"], - }); - } catch (error) { - console.error("Error in /config route:", error); - res.status(500).json(error); - } -}); - -app.get( - "/sandbox", - sandboxRateLimiter as express.RequestHandler, - (req, res) => { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const filePath = join(__dirname, "..", "static", "sandbox_proxy.html"); - let sandboxHtml; - - try { - sandboxHtml = readFileSync(filePath, "utf-8"); - } catch (e) { - sandboxHtml = "MCP Apps sandbox not loaded: " + e; - } - - res.set("Cache-Control", "no-cache, no-store, max-age=0"); - res.send(sandboxHtml); - }, -); - -const PORT = parseInt( - process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, - 10, -); -const HOST = process.env.HOST || "localhost"; - -const server = app.listen(PORT, HOST); -server.on("listening", () => { - console.log(`āš™ļø Proxy server listening on ${HOST}:${PORT}`); - if (!authDisabled) { - console.log( - `šŸ”‘ Session token: ${sessionToken}\n ` + - `Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`, - ); - } else { - console.log( - `āš ļø WARNING: Authentication is disabled. This is not recommended.`, - ); - } -}); -server.on("error", (err) => { - if (err.message.includes(`EADDRINUSE`)) { - console.error(`āŒ Proxy Server PORT IS IN USE at port ${PORT} āŒ `); - } else { - console.error(err.message); - } - process.exit(1); -}); diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts deleted file mode 100644 index 174eef0ec..000000000 --- a/server/src/mcpProxy.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; - -function onClientError(error: Error) { - console.error("Error from inspector client:", error); -} - -function onServerError(error: Error) { - if (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) { - console.error("Connection refused. Is the MCP server running?"); - } else if (error.message && error.message.includes("404")) { - console.error("Error accessing endpoint (HTTP 404)"); - } else { - console.error("Error from MCP server:", error); - } -} - -export default function mcpProxy({ - transportToClient, - transportToServer, -}: { - transportToClient: Transport; - transportToServer: Transport; -}) { - let transportToClientClosed = false; - let transportToServerClosed = false; - - let reportedServerSession = false; - - transportToClient.onmessage = (message) => { - transportToServer.send(message).catch((error) => { - // Send error response back to client if it was a request (has id) and connection is still open - if (isJSONRPCRequest(message) && !transportToClientClosed) { - const errorResponse = { - jsonrpc: "2.0" as const, - id: message.id, - error: { - code: -32001, - message: error.cause - ? `${error.message} (cause: ${error.cause})` - : error.message, - data: error, - }, - }; - transportToClient.send(errorResponse).catch(onClientError); - } - }); - }; - - transportToServer.onmessage = (message) => { - if (!reportedServerSession) { - if (transportToServer.sessionId) { - // Can only report for StreamableHttp - console.error( - "Proxy <-> Server sessionId: " + transportToServer.sessionId, - ); - } - reportedServerSession = true; - } - transportToClient.send(message).catch(onClientError); - }; - - transportToClient.onclose = () => { - if (transportToServerClosed) { - return; - } - - transportToClientClosed = true; - transportToServer.close().catch(onServerError); - }; - - transportToServer.onclose = () => { - if (transportToClientClosed) { - return; - } - transportToServerClosed = true; - transportToClient.close().catch(onClientError); - }; - - transportToClient.onerror = onClientError; - transportToServer.onerror = onServerError; -} diff --git a/test-servers/README.md b/test-servers/README.md new file mode 100644 index 000000000..e4320f5b2 --- /dev/null +++ b/test-servers/README.md @@ -0,0 +1,250 @@ +# Inspector Test Server + +This package (`@modelcontextprotocol/inspector-test-server`, `test-servers/`) provides **server infrastructure** for MCP testing: a composable MCP server implementation plus reusable fixtures. You can use it in two ways: + +1. **API** — Create servers programmatically via `createMcpServer(config)` and fixture factories (`createEchoTool()`, `createNumberedResources()`, etc.). Inspector's core, CLI, and other packages use this for tests. +2. **Composable CLI** — Run a config-driven MCP server without writing code. The `server-composable` binary reads a JSON or YAML config, resolves preset references to fixtures, and starts stdio or HTTP transport. + +--- + +## Relationship to the "Everything" Server + +The **@modelcontextprotocol/server-everything** package is the standard test-bench server for MCP clients. It provides a fixed, comprehensive server with many protocol features. + +The composable test server is **complementary**, not a replacement: + +| Situation | Composable server advantage | Everything server | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| **Testing specific capability combinations** | Compose exactly the subset you need (e.g. tools only, resources only, tasks + resources). | Fixed "kitchen sink" shape. | +| **Pagination testing** | `maxPageSize` configurable per list type. Test cursor behavior with small page sizes (e.g. 2 or 3). | Fixed resource/prompt counts. No configurable pagination. | +| **Controlled, predictable behavior** | No unexpected notifications, log evenets, etc. Behavior is focussed and deterministic. | Various log messages, notifications, subscription updates, etc. | +| **listChanged / subscriptions** | Enable or disable `listChanged` per list type. `subscriptions` toggle for resource updates. | Fixed behavior. | +| **Task variants** | Test task tools in isolation: immediate, progress, elicitation, sampling, optional vs required. | Has task-like behavior in a fixed form. | +| **Rapid iteration** | Swap config files to test different server shapes without code changes. | Single fixed shape. | + +**Use Everything when:** You want broad coverage, community standard, quick `npx` start, or hosted DCR-only OAuth for testing against a real auth server. + +**Use the composable server when:** You need focused testing of pagination, list changes, tasks, capability subsets, or reproducible scenarios. + +--- + +## API Usage + +### Components + +- **`createMcpServer(config: ServerConfig)`** — Takes `ServerConfig` and returns an MCP `McpServer`. Handles capabilities, registration, and handlers. +- **`ServerConfig`** — Configures `serverInfo`, `tools`, `resources`, `resourceTemplates`, `prompts`, capabilities (`logging`, `listChanged`, `subscriptions`, `tasks`), transport (`serverType`, `port`), `maxPageSize`. +- **Fixtures** — Factory functions in `test-server-fixtures.ts`: `createEchoTool()`, `createAddTool()`, `createNumberedTools(count)`, `createArchitectureResource()`, etc. +- **Transports** — `StdioServerTransport` (test-server-stdio.ts), `StreamableHTTPServerTransport` / `SSEServerTransport` (test-server-http.ts). + +### Example + +```ts +import { createMcpServer } from "@modelcontextprotocol/inspector-test-server"; +import { + createEchoTool, + createAddTool, +} from "@modelcontextprotocol/inspector-test-server"; + +const server = createMcpServer({ + serverInfo: { name: "my-server", version: "1.0.0" }, + tools: [createEchoTool(), createAddTool()], +}); +``` + +--- + +## Composable CLI Usage + +### Running the Server + +```bash +server-composable --config ./my-server-config.json +server-composable --config ./my-server-config.yaml # format inferred from extension +server-composable --config ./my-server-config --yaml # explicit format when extension absent +``` + +From the Inspector repo after `npm run build`: + +```bash +npm run server-composable -- --config test-servers/configs/demo.json +# or +node test-servers/build/server-composable.js --config test-servers/configs/demo.json +``` + +### Config File Format + +The config file uses **preset references** instead of inline definitions. Tool, resource, and prompt definitions include handler functions that cannot be serialized in JSON/YAML, so the config references named presets that resolve to fixture instances. + +**Preset reference format:** + +```json +{ + "preset": "", + "params": { ... } +} +``` + +- `preset` (required): name of a known preset +- `params` (optional): parameters for that preset + +**Example config (stdio):** + +```json +{ + "serverInfo": { + "name": "my-demo-server", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "add" }, + { "preset": "numbered_tools", "params": { "count": 5 } }, + { + "preset": "simple_task", + "params": { "name": "slow_task", "delayMs": 3000 } + } + ], + "resources": [{ "preset": "architecture" }, { "preset": "test_cwd" }], + "resourceTemplates": [{ "preset": "file" }, { "preset": "user" }], + "prompts": [{ "preset": "simple_prompt" }, { "preset": "args_prompt" }], + "logging": true, + "listChanged": { "tools": true, "resources": true, "prompts": true }, + "subscriptions": false, + "tasks": { "list": true, "cancel": true }, + "transport": { "type": "stdio" } +} +``` + +**Example config (HTTP):** + +```json +{ + "serverInfo": { "name": "http-demo", "version": "1.0.0" }, + "tools": [{ "preset": "echo" }], + "transport": { + "type": "streamable-http", + "port": 3000 + } +} +``` + +Use `"type": "sse"` with `port` for SSE transport. + +### Config Schema + +```ts +interface ConfigFile { + serverInfo: { name: string; version: string }; + tools?: Array; + resources?: PresetRef[]; + resourceTemplates?: PresetRef[]; + prompts?: PresetRef[]; + logging?: boolean; + listChanged?: { tools?: boolean; resources?: boolean; prompts?: boolean }; + subscriptions?: boolean; + tasks?: { list?: boolean; cancel?: boolean }; + maxPageSize?: { + tools?: number; + resources?: number; + resourceTemplates?: number; + prompts?: number; + }; + transport: { type: "stdio" | "streamable-http" | "sse"; port?: number }; +} + +interface PresetRef { + preset: string; + params?: Record; +} +``` + +Format: JSON or YAML. Infer from extension (`.json`, `.yaml`, `.yml`), or use `--json` / `--yaml` to override. + +### Preset Registry + +| Preset Name | Type | Params | Notes | +| ------------------------------------------------------------------------------------------------ | ------------------ | ------------------------------- | ----------------------------------------------------------- | +| echo | tool | none | Echo tool | +| add | tool | none | Add two numbers | +| get_sum | tool | none | Alias for add | +| write_to_stderr | tool | none | Writes message to stderr | +| collect_sample | tool | none | Sends sampling request to client | +| list_roots | tool | none | Calls roots/list on client | +| collect_elicitation | tool | none | Sends form elicitation request | +| collect_url_elicitation | tool | none | Sends URL elicitation request | +| url_elicitation_form | tool | message? | Hosts form server, URL elicitation, returns submitted value | +| send_notification | tool | none | Sends notification to client | +| get_annotated_message | tool | none | Returns annotated message + optional image | +| add_resource, remove_resource, add_tool, remove_tool, add_prompt, remove_prompt, update_resource | tool | none | Dynamic list changes | +| send_progress | tool | name? | Sends progress notifications | +| numbered_tools | tool[] | count | Creates N echo-like tools | +| simple_task | taskTool | name?, delayMs? | Task that completes after delay | +| progress_task | taskTool | name?, delayMs?, progressUnits? | Task with progress | +| elicitation_task | taskTool | name? | Task requiring form elicitation | +| sampling_task | taskTool | name?, samplingText? | Task requiring sampling | +| optional_task | taskTool | name?, delayMs? | Task with optional task support | +| forbidden_task | tool | name?, delayMs? | Non-task tool (completes immediately) | +| immediate_return_task | tool | name?, delayMs? | Immediate return (no task) | +| architecture | resource | none | Static architecture doc | +| test_cwd, test_env, test_argv | resource | none | Expose process.cwd(), env, argv | +| numbered_resources | resource[] | count | N static resources | +| file | resourceTemplate | none | file:///{path} template | +| user | resourceTemplate | none | user://{userId} template | +| numbered_resource_templates | resourceTemplate[] | count | N templates | +| simple_prompt | prompt | none | Simple static prompt | +| args_prompt | prompt | none | Prompt with city, state args | +| numbered_prompts | prompt[] | count | N static prompts | + +### Transport and Client Config + +**Stdio:** The server runs as a subprocess. In MCP Inspector (or any MCP client) config, use stdio with the script as the command: + +```json +{ + "mcpServers": { + "demo": { + "command": "node", + "args": [ + "test-servers/build/server-composable.js", + "--config", + "test-servers/configs/demo.json" + ] + } + } +} +``` + +Ensure the working directory is correct (e.g. Inspector repo root) or use absolute paths. + +**Streamable HTTP or SSE:** Run the server yourself first (it binds to the configured port). Then in your client config, use the URL: + +```json +{ + "mcpServers": { + "demo": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp" + } + } +} +``` + +For SSE, use `"type": "sse"` and a URL ending in `/sse` (e.g. `http://localhost:3000/sse`). + +--- + +## Limitations + +- **Presets only** — Config cannot define custom handlers. New tools/resources require code or new presets. +- **OAuth** — Deferred. Not available in the composable server yet. +- **Elicitation/sampling** — Config uses fixture default schemas. No custom schema in config. +- **Completion callbacks** — Presets like `file` and `args_prompt` support them; config-driven mode uses defaults. + +--- + +## Sample Configs + +See `configs/` for example configs: + +- **demo.json** — Minimal server with echo tool only (stdio). Use for smoke testing. diff --git a/test-servers/__tests__/server-composable.test.ts b/test-servers/__tests__/server-composable.test.ts new file mode 100644 index 000000000..455affe46 --- /dev/null +++ b/test-servers/__tests__/server-composable.test.ts @@ -0,0 +1,50 @@ +/** + * Integration test: run composable server from demo.json, connect via MCP SDK, verify, shut down + */ + +import { describe, it, expect } from "vitest"; +import path from "path"; +import { fileURLToPath } from "url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("server-composable", () => { + it("should run composable server from demo.json, list tools via MCP client, then shut down", async () => { + const scriptPath = path.join(__dirname, "../build/server-composable.js"); + const configPath = path.join(__dirname, "../configs/demo.json"); + const transport = new StdioClientTransport({ + command: "node", + args: [scriptPath, "--config", configPath], + cwd: path.join(__dirname, ".."), + }); + + const client = new Client( + { name: "composable-test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + + await client.connect(transport); + + try { + const { tools } = await client.listTools(); + const echoTool = tools.find((t) => t.name === "echo"); + expect(echoTool).toBeDefined(); + + const result = await client.callTool({ + name: "echo", + arguments: { message: "hello from test" }, + }); + expect(result.content).toBeDefined(); + const content = result.content as Array<{ type: string; text?: string }>; + expect(Array.isArray(content)).toBe(true); + const textContent = content.find((c) => c.type === "text"); + expect(textContent).toBeDefined(); + expect(textContent!.text).toContain("Echo:"); + expect(textContent!.text).toContain("hello from test"); + } finally { + await transport.close(); + } + }); +}); diff --git a/test-servers/__tests__/url-elicitation-form.test.ts b/test-servers/__tests__/url-elicitation-form.test.ts new file mode 100644 index 000000000..4b98afd0a --- /dev/null +++ b/test-servers/__tests__/url-elicitation-form.test.ts @@ -0,0 +1,99 @@ +/** + * Integration test: url_elicitation_form tool + * + * Spins up composable server with url_elicitation_form tool, calls it, + * accepts the URL elicitation, submits the form with test data, and + * verifies elicitation completion and tool response. + */ + +import { describe, it, expect } from "vitest"; +import path from "path"; +import { fileURLToPath } from "url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { + ElicitRequestSchema, + ElicitationCompleteNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEST_VALUE = "test-input-value-42"; + +describe("url_elicitation_form", () => { + it("should call tool, accept elicitation, submit form, and return collected value", async () => { + const scriptPath = path.join(__dirname, "../build/server-composable.js"); + const configPath = path.join( + __dirname, + "../configs/url-elicitation-form.json", + ); + const transport = new StdioClientTransport({ + command: "node", + args: [scriptPath, "--config", configPath], + cwd: path.join(__dirname, ".."), + }); + + let elicitedUrl: string | null = null; + let elicitedId: string | null = null; + let completionReceived = false; + + const client = new Client( + { name: "url-elicitation-form-test-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { url: {} }, + }, + }, + ); + + client.setRequestHandler(ElicitRequestSchema, async (request) => { + if (request.params?.mode === "url") { + elicitedUrl = request.params.url as string; + elicitedId = request.params.elicitationId as string; + // Submit form asynchronously after returning accept + Promise.resolve().then(async () => { + const formData = new URLSearchParams({ + value: TEST_VALUE, + elicitation: elicitedId!, + }); + await fetch(elicitedUrl!, { + method: "POST", + body: formData, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + }); + } + return { action: "accept" as const }; + }); + + client.setNotificationHandler(ElicitationCompleteNotificationSchema, () => { + completionReceived = true; + }); + + await client.connect(transport); + + try { + const { tools } = await client.listTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("url_elicitation_form"); + + const result = await client.callTool({ + name: "url_elicitation_form", + arguments: {}, + }); + + expect(result.content).toBeDefined(); + const content = result.content as Array<{ type: string; text?: string }>; + expect(Array.isArray(content)).toBe(true); + const textContent = content.find((c) => c.type === "text"); + expect(textContent).toBeDefined(); + expect(textContent!.text).toContain("Collected value:"); + expect(textContent!.text).toContain(TEST_VALUE); + + expect(elicitedUrl).toBeTruthy(); + expect(elicitedId).toBeTruthy(); + expect(completionReceived).toBe(true); + } finally { + await transport.close(); + } + }); +}); diff --git a/test-servers/configs/README.md b/test-servers/configs/README.md new file mode 100644 index 000000000..e6bf7a9e1 --- /dev/null +++ b/test-servers/configs/README.md @@ -0,0 +1,5 @@ +# Composable Test Server Configs + +Sample config files for the composable test server (`server-composable --config `). + +- **demo.json** — Minimal server with echo tool only (stdio). Use for smoke testing. diff --git a/test-servers/configs/demo.json b/test-servers/configs/demo.json new file mode 100644 index 000000000..2c18f0e47 --- /dev/null +++ b/test-servers/configs/demo.json @@ -0,0 +1,10 @@ +{ + "serverInfo": { + "name": "composable-demo", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "get_temp" }], + "transport": { + "type": "stdio" + } +} diff --git a/test-servers/configs/url-elicitation-form.json b/test-servers/configs/url-elicitation-form.json new file mode 100644 index 000000000..6d6325177 --- /dev/null +++ b/test-servers/configs/url-elicitation-form.json @@ -0,0 +1,5 @@ +{ + "serverInfo": { "name": "url-elicitation-form-test", "version": "1.0.0" }, + "tools": [{ "preset": "url_elicitation_form" }], + "transport": { "type": "stdio" } +} diff --git a/test-servers/package.json b/test-servers/package.json new file mode 100644 index 000000000..c7e643c9f --- /dev/null +++ b/test-servers/package.json @@ -0,0 +1,35 @@ +{ + "name": "@modelcontextprotocol/inspector-test-server", + "version": "0.20.0", + "private": true, + "description": "Composable MCP test servers, fixtures, and harness for Inspector", + "type": "module", + "main": "./build/index.js", + "types": "./build/index.d.ts", + "exports": { + ".": "./build/index.js", + "./*": "./build/*.js" + }, + "files": [ + "build" + ], + "scripts": { + "build": "tsc", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "express": "^5.1.0", + "yaml": "^2.8.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.17.0", + "typescript": "^5.4.2", + "vitest": "^4.0.17" + }, + "bin": { + "server-composable": "./build/server-composable.js" + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts new file mode 100644 index 000000000..305ea4e59 --- /dev/null +++ b/test-servers/src/composable-test-server.ts @@ -0,0 +1,959 @@ +/** + * Composable Test Server + * + * Provides types and functions for creating MCP test servers from configuration. + * This allows composing MCP test servers with different capabilities, tools, resources, and prompts. + */ + +import { + McpServer, + ResourceTemplate as SdkResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + Implementation, + Tool, + Resource, + ResourceTemplate, + Prompt, + CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { + InMemoryTaskStore, + InMemoryTaskMessageQueue, +} from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js"; +import type { + TaskStore, + TaskMessageQueue, + ToolTaskHandler, +} from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js"; +import type { + RegisteredTool, + RegisteredResource, + RegisteredPrompt, + RegisteredResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + ServerRequest, + ServerNotification, +} from "@modelcontextprotocol/sdk/types.js"; +import { + SetLevelRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListPromptsRequestSchema, + type ListToolsResult, + type ListResourcesResult, + type ListResourceTemplatesResult, + type ListPromptsResult, +} from "@modelcontextprotocol/sdk/types.js"; +import type { AnySchema } from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import { + ZodRawShapeCompat, + getObjectShape, + getSchemaDescription, + isSchemaOptional, + normalizeObjectSchema, +} from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; +import { + completable, + isCompletable, +} from "@modelcontextprotocol/sdk/server/completable.js"; +import type { PromptArgument } from "@modelcontextprotocol/sdk/types.js"; + +// Empty object JSON schema constant (from SDK's mcp.js) +const EMPTY_OBJECT_JSON_SCHEMA = { + type: "object", + properties: {}, +} as const; + +type ToolInputSchema = ZodRawShapeCompat; +type PromptArgsSchema = ZodRawShapeCompat; + +interface ServerState { + registeredTools: Map; // Keyed by name + registeredResources: Map; // Keyed by URI + registeredPrompts: Map; // Keyed by name + registeredResourceTemplates: Map; // Keyed by uriTemplate + listChangedConfig: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + }; + resourceSubscriptions: Set; // Set of subscribed resource URIs +} + +/** + * Context object passed to tool handlers containing both server and state + */ +export interface TestServerContext { + server: McpServer; + state: ServerState; + serverControl?: { isClosing(): boolean }; +} + +export interface ToolDefinition { + name: string; + description: string; + inputSchema?: ToolInputSchema; + /** Optional Zod object schema for tool output; when set, handler must return structuredContent. */ + outputSchema?: unknown; + handler: ( + params: Record, + context?: TestServerContext, + extra?: RequestHandlerExtra, + ) => Promise; +} + +export interface TaskToolDefinition { + name: string; + description: string; + inputSchema?: ToolInputSchema; + execution?: { taskSupport: "required" | "optional" }; + handler: ToolTaskHandler; +} + +export interface ResourceDefinition { + uri: string; + name: string; + description?: string; + mimeType?: string; + text?: string; +} + +export interface PromptDefinition { + name: string; + description?: string; + promptString: string; // The prompt text with optional {argName} placeholders + argsSchema?: PromptArgsSchema; // Can include completable() schemas + // Optional completion callbacks keyed by argument name + // This is a convenience - users can also use completable() directly in argsSchema + completions?: Record< + string, + ( + argumentValue: string, + context?: Record, + ) => Promise | string[] + >; +} + +export interface ResourceTemplateDefinition { + name: string; + uriTemplate: string; // URI template with {variable} placeholders (RFC 6570) + description?: string; + inputSchema?: ZodRawShapeCompat; // Schema for template variables + handler: ( + uri: URL, + params: Record, + context?: TestServerContext, + extra?: RequestHandlerExtra, + ) => Promise<{ + contents: Array<{ uri: string; mimeType?: string; text: string }>; + }>; + // Optional callbacks for resource template operations + // list: Can return either: + // - string[] (convenience - will be converted to ListResourcesResult with uri and name) + // - ListResourcesResult (full control - includes uri, name, description, mimeType, etc.) + list?: + | (() => Promise | string[]) + | (() => Promise | ListResourcesResult); + // complete: Map of variable names to completion callbacks + // OR a single callback function that will be used for all variables + complete?: + | Record< + string, + ( + value: string, + context?: Record, + ) => Promise | string[] + > + | (( + argumentName: string, + argumentValue: string, + context?: Record, + ) => Promise | string[]); +} + +/** + * Configuration for composing an MCP server + */ +export interface ServerConfig { + serverInfo: Implementation; // Server metadata (name, version, etc.) - required + tools?: (ToolDefinition | TaskToolDefinition)[]; // Tools to register (optional, empty array means no tools, but tools capability is still advertised) + resources?: ResourceDefinition[]; // Resources to register (optional, empty array means no resources, but resources capability is still advertised) + resourceTemplates?: ResourceTemplateDefinition[]; // Resource templates to register (optional, empty array means no templates, but resources capability is still advertised) + prompts?: PromptDefinition[]; // Prompts to register (optional, empty array means no prompts, but prompts capability is still advertised) + logging?: boolean; // Whether to advertise logging capability (default: false) + onLogLevelSet?: (level: string) => void; // Optional callback when log level is set (for testing) + onRegisterResource?: (resource: ResourceDefinition) => + | (() => Promise<{ + contents: Array<{ uri: string; mimeType?: string; text: string }>; + }>) + | undefined; // Optional callback to customize resource handler during registration + serverType?: "sse" | "streamable-http"; // Transport type (default: "streamable-http") + port?: number; // Port to use (optional, will find available port if not specified) + /** + * Whether to advertise listChanged capability for each list type + * If enabled, modification tools will send list_changed notifications + */ + listChanged?: { + tools?: boolean; // default: false + resources?: boolean; // default: false + prompts?: boolean; // default: false + }; + /** + * Whether to advertise resource subscriptions capability + * If enabled, server will advertise resources.subscribe capability + */ + subscriptions?: boolean; // default: false + /** + * Maximum page size for pagination (optional, undefined means no pagination) + * When set, custom list handlers will paginate results using this page size + */ + maxPageSize?: { + tools?: number; + resources?: number; + resourceTemplates?: number; + prompts?: number; + }; + /** + * Whether to advertise tasks capability + * If enabled, server will advertise tasks capability with list and cancel support + */ + tasks?: { + list?: boolean; // default: true + cancel?: boolean; // default: true + }; + /** + * Task store implementation (optional, defaults to InMemoryTaskStore) + * Only used if tasks capability is enabled + */ + taskStore?: TaskStore; + /** + * Task message queue implementation (optional, defaults to InMemoryTaskMessageQueue) + * Only used if tasks capability is enabled + */ + taskMessageQueue?: TaskMessageQueue; + /** + * OAuth 2.1 configuration for test server + * If enabled, server will act as an OAuth authorization server + */ + oauth?: { + /** + * Whether OAuth is enabled for this test server + */ + enabled: boolean; + + /** + * OAuth authorization server issuer URL + * Used for metadata endpoints and token issuance + * If not provided, defaults to the test server's base URL + */ + issuerUrl?: URL; + + /** + * List of scopes supported by this authorization server + * Defaults to ["mcp"] if not provided + */ + scopesSupported?: string[]; + + /** + * If true, MCP endpoints require valid Bearer token + * Returns 401 Unauthorized if token is missing or invalid + */ + requireAuth?: boolean; + + /** + * Static/preregistered clients for testing + * These clients are pre-configured and don't require DCR + */ + staticClients?: Array<{ + clientId: string; + clientSecret?: string; + redirectUris?: string[]; + }>; + + /** + * Whether to support Dynamic Client Registration (DCR) + * If true, exposes /register endpoint for client registration + */ + supportDCR?: boolean; + + /** + * Whether to support CIMD (Client ID Metadata Documents) + * If true, server will fetch client metadata from clientMetadataUrl + */ + supportCIMD?: boolean; + + /** + * Token expiration time in seconds (default: 3600) + */ + tokenExpirationSeconds?: number; + + /** + * Whether to support refresh tokens (default: true) + */ + supportRefreshTokens?: boolean; + }; + /** + * Optional server control for orderly shutdown (test HTTP server). + * When present, progress-sending tools check isClosing() before sending and skip/break if closing. + */ + serverControl?: { isClosing(): boolean }; +} + +/** + * Create and configure an McpServer instance from ServerConfig + * This centralizes the setup logic shared between HTTP and stdio test servers + */ +export function createMcpServer(config: ServerConfig): McpServer { + // Build capabilities based on config + const capabilities: { + tools?: object; + resources?: { subscribe?: boolean }; + prompts?: object; + logging?: object; + tasks?: { + list?: object; + cancel?: object; + requests?: { tools?: { call?: object } }; + }; + } = {}; + + if (config.tools !== undefined) { + capabilities.tools = {}; + } + if ( + config.resources !== undefined || + config.resourceTemplates !== undefined + ) { + capabilities.resources = {}; + // Add subscribe capability if subscriptions are enabled + if (config.subscriptions === true) { + capabilities.resources.subscribe = true; + } + } + if (config.prompts !== undefined) { + capabilities.prompts = {}; + } + if (config.logging === true) { + capabilities.logging = {}; + } + if (config.tasks !== undefined) { + capabilities.tasks = { + list: config.tasks.list !== false ? {} : undefined, + cancel: config.tasks.cancel !== false ? {} : undefined, + requests: { tools: { call: {} } }, + }; + // Remove undefined values + if (capabilities.tasks.list === undefined) { + delete capabilities.tasks.list; + } + if (capabilities.tasks.cancel === undefined) { + delete capabilities.tasks.cancel; + } + } + + // Create task store and message queue if tasks are enabled + const taskStore = + config.tasks !== undefined + ? config.taskStore || new InMemoryTaskStore() + : undefined; + const taskMessageQueue = + config.tasks !== undefined + ? config.taskMessageQueue || new InMemoryTaskMessageQueue() + : undefined; + + // Create the server with capabilities and task stores + const mcpServer = new McpServer(config.serverInfo, { + capabilities, + taskStore, + taskMessageQueue, + }); + + // Create state (this is really session state, which is what we'll call it if we implement sessions at some point) + const state: ServerState = { + registeredTools: new Map(), // Keyed by name + registeredResources: new Map(), // Keyed by URI + registeredPrompts: new Map(), // Keyed by name + registeredResourceTemplates: new Map(), // Keyed by uriTemplate + listChangedConfig: config.listChanged || {}, + resourceSubscriptions: new Set(), // Track subscribed resource URIs + }; + + // Create context object + const context: TestServerContext = { + server: mcpServer, + state, + ...(config.serverControl && { serverControl: config.serverControl }), + }; + + // Set up logging handler if logging is enabled + if (config.logging === true) { + mcpServer.server.setRequestHandler( + SetLevelRequestSchema, + async (request) => { + // Call optional callback if provided (for testing) + if (config.onLogLevelSet) { + config.onLogLevelSet(request.params.level); + } + // Return empty result as per MCP spec + return {}; + }, + ); + } + + // Set up resource subscription handlers if subscriptions are enabled + if (config.subscriptions === true) { + mcpServer.server.setRequestHandler( + SubscribeRequestSchema, + async (request) => { + // Track subscription in state (accessible via closure) + const uri = request.params.uri; + state.resourceSubscriptions.add(uri); + return {}; + }, + ); + + mcpServer.server.setRequestHandler( + UnsubscribeRequestSchema, + async (request) => { + // Remove subscription from state (accessible via closure) + const uri = request.params.uri; + state.resourceSubscriptions.delete(uri); + return {}; + }, + ); + } + + // Type guard to check if a tool is a task tool + function isTaskTool( + tool: ToolDefinition | TaskToolDefinition, + ): tool is TaskToolDefinition { + return ( + "handler" in tool && + typeof tool.handler === "object" && + tool.handler !== null && + "createTask" in tool.handler + ); + } + + // Set up tools + if (config.tools && config.tools.length > 0) { + for (const tool of config.tools) { + if (isTaskTool(tool)) { + // Register task-based tool + // registerToolTask has two overloads: one with inputSchema (required) and one without + const registered = tool.inputSchema + ? mcpServer.experimental.tasks.registerToolTask( + tool.name, + { + description: tool.description, + inputSchema: tool.inputSchema, + execution: tool.execution, + }, + tool.handler, + ) + : mcpServer.experimental.tasks.registerToolTask( + tool.name, + { + description: tool.description, + execution: tool.execution, + }, + tool.handler, + ); + state.registeredTools.set(tool.name, registered); + } else { + // Register regular tool + const registered = mcpServer.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.inputSchema, + ...(tool.outputSchema != null && { + outputSchema: tool.outputSchema as AnySchema, + }), + }, + async (args, extra) => { + const result = await tool.handler( + args as Record, + context, + extra, + ); + const rawStructured = + result && + typeof result === "object" && + "structuredContent" in result + ? (result as { structuredContent?: unknown }).structuredContent + : undefined; + const structuredContent = + rawStructured !== undefined && rawStructured !== null + ? (rawStructured as Record) + : undefined; + // If handler returns content array, use it; otherwise build content from message or stringify + let content: Array<{ type: "text"; text: string }>; + if (result && Array.isArray(result.content)) { + content = result.content as Array<{ type: "text"; text: string }>; + } else if (result && typeof result.message === "string") { + content = [{ type: "text" as const, text: result.message }]; + } else { + content = [ + { + type: "text" as const, + text: JSON.stringify(result ?? {}), + }, + ]; + } + return { + content, + ...(structuredContent !== undefined && { structuredContent }), + }; + }, + ); + state.registeredTools.set(tool.name, registered); + } + } + } + + // Set up resources + if (config.resources && config.resources.length > 0) { + for (const resource of config.resources) { + // Check if there's a custom handler from the callback + const customHandler = config.onRegisterResource + ? config.onRegisterResource(resource) + : undefined; + + const registered = mcpServer.registerResource( + resource.name, + resource.uri, + { + description: resource.description, + mimeType: resource.mimeType, + }, + customHandler || + (async () => { + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType || "text/plain", + text: resource.text ?? "", + }, + ], + }; + }), + ); + state.registeredResources.set(resource.uri, registered); + } + } + + // Set up resource templates + if (config.resourceTemplates && config.resourceTemplates.length > 0) { + for (const template of config.resourceTemplates) { + // ResourceTemplate is a class - create an instance with the URI template string and callbacks + // Convert list callback: SDK expects ListResourcesResult + // We support both string[] (convenience) and ListResourcesResult (full control) + const listCallback = template.list + ? async () => { + const result = template.list!(); + const resolved = await result; + // Check if it's already a ListResourcesResult (has resources array) + if ( + resolved && + typeof resolved === "object" && + "resources" in resolved + ) { + return resolved as ListResourcesResult; + } + // Otherwise, it's string[] - convert to ListResourcesResult + const uriArray = resolved as string[]; + return { + resources: uriArray.map((uri) => ({ + uri, + name: uri, // Use URI as name if not provided + })), + }; + } + : undefined; + + // Convert complete callback: SDK expects {[variable: string]: callback} + // We support either a map or a single function + let completeCallbacks: + | { + [variable: string]: ( + value: string, + context?: { arguments?: Record }, + ) => Promise | string[]; + } + | undefined = undefined; + + if (template.complete) { + if (typeof template.complete === "function") { + // Single function - extract variable names from URI template and use for all + // Parse URI template to find variables (e.g., {file} from "file://{file}") + const variableMatches = template.uriTemplate.match(/\{([^}]+)\}/g); + if (variableMatches) { + completeCallbacks = {}; + const completeFn = template.complete; + for (const match of variableMatches) { + const variableName = match.slice(1, -1); // Remove { and } + completeCallbacks[variableName] = async ( + value: string, + context?: { arguments?: Record }, + ) => { + const result = completeFn( + variableName, + value, + context?.arguments, + ); + return Array.isArray(result) ? result : await result; + }; + } + } + } else { + // Map of variable names to callbacks + completeCallbacks = {}; + for (const [variableName, callback] of Object.entries( + template.complete, + )) { + completeCallbacks[variableName] = async ( + value: string, + context?: { arguments?: Record }, + ) => { + const result = callback(value, context?.arguments); + return Array.isArray(result) ? result : await result; + }; + } + } + } + + const resourceTemplate = new SdkResourceTemplate(template.uriTemplate, { + list: listCallback, + complete: completeCallbacks, + }); + + const registered = mcpServer.registerResource( + template.name, + resourceTemplate, + { + description: template.description, + }, + async (uri: URL, variables: Record, extra) => { + const result = await template.handler(uri, variables, context, extra); + return result; + }, + ); + state.registeredResourceTemplates.set(template.uriTemplate, registered); + } + } + + // Set up prompts + if (config.prompts && config.prompts.length > 0) { + for (const prompt of config.prompts) { + // Build argsSchema with completion support if provided + let argsSchema = prompt.argsSchema; + + // If completions callbacks are provided, wrap the corresponding schemas + if (prompt.completions && argsSchema) { + const enhancedSchema: ZodRawShapeCompat = { ...argsSchema }; + for (const [argName, completeCallback] of Object.entries( + prompt.completions, + )) { + if (enhancedSchema[argName]) { + // Wrap with completable only if not already wrapped (avoids "Cannot redefine property" when createMcpServer is called multiple times with shared config) + if (!isCompletable(enhancedSchema[argName])) { + enhancedSchema[argName] = completable( + enhancedSchema[argName], + async ( + value: unknown, + context?: { arguments?: Record }, + ) => { + const result = completeCallback( + String(value), + context?.arguments, + ); + return Array.isArray(result) ? result : await result; + }, + ); + } + } + } + argsSchema = enhancedSchema; + } + + const registered = mcpServer.registerPrompt( + prompt.name, + { + description: prompt.description, + argsSchema: argsSchema, + }, + async (args) => { + let text = prompt.promptString; + + // If args are provided, substitute them into the prompt string + // Replace {argName} with the actual value + if (args && typeof args === "object") { + for (const [key, value] of Object.entries(args)) { + const placeholder = `{${key}}`; + text = text.replace( + new RegExp(placeholder.replace(/[{}]/g, "\\$&"), "g"), + String(value), + ); + } + } + + return { + messages: [ + { + role: "user", + content: { + type: "text", + text, + }, + }, + ], + }; + }, + ); + state.registeredPrompts.set(prompt.name, registered); + } + } + + // Set up pagination handlers if maxPageSize is configured + const maxPageSize = config.maxPageSize || {}; + + // Tools pagination + if (capabilities.tools && maxPageSize.tools !== undefined) { + mcpServer.server.setRequestHandler( + ListToolsRequestSchema, + async (request) => { + const cursor = request.params?.cursor; + const pageSize = maxPageSize.tools!; + + // Convert registered tools to Tool format using the same logic as the SDK (mcp.js lines 67-95) + const allTools: Tool[] = []; + for (const [name, registered] of state.registeredTools.entries()) { + if (registered.enabled) { + // Match SDK's approach exactly (mcp.js lines 71-95) + const toolDefinition: Record = { + name, + title: registered.title, + description: registered.description, + inputSchema: (() => { + const obj = normalizeObjectSchema(registered.inputSchema); + return obj + ? toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: "input", + }) + : EMPTY_OBJECT_JSON_SCHEMA; + })(), + annotations: registered.annotations, + execution: registered.execution, + _meta: registered._meta, + }; + + if (registered.outputSchema) { + const obj = normalizeObjectSchema(registered.outputSchema); + if (obj) { + toolDefinition.outputSchema = toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: "output", + }); + } + } + + allTools.push(toolDefinition as Tool); + } + } + + const startIndex = cursor ? parseInt(cursor, 10) : 0; + const endIndex = startIndex + pageSize; + const page = allTools.slice(startIndex, endIndex); + const nextCursor = + endIndex < allTools.length ? endIndex.toString() : undefined; + + return { + tools: page, + nextCursor, + } as ListToolsResult; + }, + ); + } + + // Resources pagination + if (capabilities.resources && maxPageSize.resources !== undefined) { + mcpServer.server.setRequestHandler( + ListResourcesRequestSchema, + async (request, extra) => { + const cursor = request.params?.cursor; + const pageSize = maxPageSize.resources!; + + // Collect all resources (static + from templates) + const allResources: Resource[] = []; + + // Add static resources from registered resources + for (const [uri, registered] of state.registeredResources.entries()) { + if (registered.enabled) { + allResources.push({ + uri, + name: registered.name, + title: registered.title, + description: registered.metadata?.description, + mimeType: registered.metadata?.mimeType, + icons: registered.metadata?.icons, + } as Resource); + } + } + + // Add resources from templates (if list callback exists) + for (const template of state.registeredResourceTemplates.values()) { + if (template.enabled && template.resourceTemplate.listCallback) { + try { + const result = + await template.resourceTemplate.listCallback(extra); + for (const resource of result.resources) { + allResources.push({ + ...resource, + // Merge template metadata if resource doesn't have it + name: resource.name, + description: + resource.description || template.metadata?.description, + mimeType: resource.mimeType || template.metadata?.mimeType, + icons: resource.icons || template.metadata?.icons, + } as Resource); + } + } catch { + // Ignore errors from list callbacks + } + } + } + + const startIndex = cursor ? parseInt(cursor, 10) : 0; + const endIndex = startIndex + pageSize; + const page = allResources.slice(startIndex, endIndex); + const nextCursor = + endIndex < allResources.length ? endIndex.toString() : undefined; + + return { + resources: page, + nextCursor, + } as ListResourcesResult; + }, + ); + } + + // Resource templates pagination + if (capabilities.resources && maxPageSize.resourceTemplates !== undefined) { + mcpServer.server.setRequestHandler( + ListResourceTemplatesRequestSchema, + async (request) => { + const cursor = request.params?.cursor; + const pageSize = maxPageSize.resourceTemplates!; + + // Convert registered resource templates to ResourceTemplate format + const allTemplates: Array<{ + uriTemplate: string; + name: string; + description?: string; + mimeType?: string; + icons?: Array<{ + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; + }>; + title?: string; + }> = []; + for (const [ + uriTemplate, + registered, + ] of state.registeredResourceTemplates.entries()) { + if (registered.enabled) { + // Find the name from config by matching uriTemplate + const templateDef = config.resourceTemplates?.find( + (t) => t.uriTemplate === uriTemplate, + ); + allTemplates.push({ + uriTemplate: registered.resourceTemplate.uriTemplate.toString(), + name: templateDef?.name || uriTemplate, // Fallback to uriTemplate if name not found + title: registered.title, + description: + registered.metadata?.description || templateDef?.description, + mimeType: registered.metadata?.mimeType, + icons: registered.metadata?.icons, + }); + } + } + + const startIndex = cursor ? parseInt(cursor, 10) : 0; + const endIndex = startIndex + pageSize; + const page = allTemplates.slice(startIndex, endIndex); + const nextCursor = + endIndex < allTemplates.length ? endIndex.toString() : undefined; + + return { + resourceTemplates: page as ResourceTemplate[], + nextCursor, + } as ListResourceTemplatesResult; + }, + ); + } + + // Prompts pagination + if (capabilities.prompts && maxPageSize.prompts !== undefined) { + mcpServer.server.setRequestHandler( + ListPromptsRequestSchema, + async (request) => { + const cursor = request.params?.cursor; + const pageSize = maxPageSize.prompts!; + + // Convert registered prompts to Prompt format using the same logic as the SDK + const allPrompts: Prompt[] = []; + for (const [name, prompt] of state.registeredPrompts.entries()) { + if (prompt.enabled) { + // Use the same conversion logic the SDK uses (from mcp.js line 408-419) + const shape = prompt.argsSchema + ? getObjectShape(prompt.argsSchema) + : undefined; + const arguments_ = shape + ? Object.entries(shape).map(([argName, field]) => { + const description = getSchemaDescription(field); + const isOptional = isSchemaOptional(field); + return { + name: argName, + description, + required: !isOptional, + } as PromptArgument; + }) + : undefined; + + allPrompts.push({ + name, + title: prompt.title, + description: prompt.description, + arguments: arguments_, + } as Prompt); + } + } + + const startIndex = cursor ? parseInt(cursor, 10) : 0; + const endIndex = startIndex + pageSize; + const page = allPrompts.slice(startIndex, endIndex); + const nextCursor = + endIndex < allPrompts.length ? endIndex.toString() : undefined; + + return { + prompts: page, + nextCursor, + } as ListPromptsResult; + }, + ); + } + + return mcpServer; +} diff --git a/test-servers/src/index.ts b/test-servers/src/index.ts new file mode 100644 index 000000000..490743261 --- /dev/null +++ b/test-servers/src/index.ts @@ -0,0 +1,11 @@ +/** + * Composable MCP test servers, fixtures, and harness for Inspector + */ + +export * from "./composable-test-server.js"; +export * from "./test-server-fixtures.js"; +export * from "./test-server-stdio.js"; +export * from "./test-server-http.js"; +export * from "./test-server-control.js"; +export * from "./test-server-oauth.js"; +export * from "./test-helpers.js"; diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts new file mode 100644 index 000000000..1e97790d2 --- /dev/null +++ b/test-servers/src/load-config.ts @@ -0,0 +1,138 @@ +/** + * Config loader for composable test server + * Reads JSON or YAML config files with format inferred from extension or --json/--yaml flag + */ + +import { readFileSync } from "fs"; +import path from "path"; +import YAML from "yaml"; + +export interface PresetRef { + preset: string; + params?: Record; +} + +export interface ConfigFile { + serverInfo: { + name: string; + version: string; + }; + tools?: Array; + resources?: PresetRef[]; + resourceTemplates?: PresetRef[]; + prompts?: PresetRef[]; + logging?: boolean; + listChanged?: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + }; + subscriptions?: boolean; + tasks?: { + list?: boolean; + cancel?: boolean; + }; + maxPageSize?: { + tools?: number; + resources?: number; + resourceTemplates?: number; + prompts?: number; + }; + transport: { + type: "stdio" | "streamable-http" | "sse"; + port?: number; + }; +} + +export type ConfigFormat = "json" | "yaml"; + +function inferFormatFromPath(filePath: string): ConfigFormat | null { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".json") return "json"; + if (ext === ".yaml" || ext === ".yml") return "yaml"; + return null; +} + +function parseContent( + content: string, + format: ConfigFormat, + filePath: string, +): unknown { + try { + if (format === "json") { + return JSON.parse(content); + } + return YAML.parse(content); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse config file ${filePath}: ${msg}`); + } +} + +function validateConfig( + obj: unknown, + filePath: string, +): asserts obj is ConfigFile { + if (obj === null || typeof obj !== "object") { + throw new Error(`Invalid config in ${filePath}: expected object`); + } + const o = obj as Record; + if ( + !o.serverInfo || + typeof o.serverInfo !== "object" || + typeof (o.serverInfo as Record).name !== "string" || + typeof (o.serverInfo as Record).version !== "string" + ) { + throw new Error( + `Invalid config in ${filePath}: serverInfo.name and serverInfo.version are required`, + ); + } + if ( + !o.transport || + typeof o.transport !== "object" || + typeof (o.transport as Record).type !== "string" + ) { + throw new Error( + `Invalid config in ${filePath}: transport.type is required`, + ); + } + const transportType = (o.transport as Record).type as string; + if (!["stdio", "streamable-http", "sse"].includes(transportType)) { + throw new Error( + `Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`, + ); + } +} + +/** + * Load config from file. Format is inferred from extension unless overridden by format option. + * Paths in config are resolved relative to cwd. + */ +export function loadConfig( + filePath: string, + options?: { format?: ConfigFormat }, +): ConfigFile { + const explicitFormat = options?.format; + const inferredFormat = inferFormatFromPath(filePath); + + let format: ConfigFormat; + if (explicitFormat) { + format = explicitFormat; + } else if (inferredFormat) { + format = inferredFormat; + } else { + throw new Error( + `Cannot infer config format from path ${filePath}. ` + + `Use .json, .yaml, or .yml extension, or pass --json or --yaml flag`, + ); + } + + const resolvedPath = path.isAbsolute(filePath) + ? filePath + : path.resolve(process.cwd(), filePath); + + const content = readFileSync(resolvedPath, "utf-8"); + const parsed = parseContent(content, format, resolvedPath); + validateConfig(parsed, resolvedPath); + return parsed as ConfigFile; +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts new file mode 100644 index 000000000..8ca515c5f --- /dev/null +++ b/test-servers/src/preset-registry.ts @@ -0,0 +1,236 @@ +/** + * Preset registry for config-driven composable server + * Maps preset names to fixture factory functions + */ + +import type { + ToolDefinition, + TaskToolDefinition, + ResourceDefinition, + PromptDefinition, + ResourceTemplateDefinition, +} from "./composable-test-server.js"; +import { + createEchoTool, + createAddTool, + createGetSumTool, + createWriteToStderrTool, + createCollectSampleTool, + createListRootsTool, + createCollectFormElicitationTool, + createCollectUrlElicitationTool, + createUrlElicitationFormTool, + createSendNotificationTool, + createGetAnnotatedMessageTool, + createGetTempTool, + createAddResourceTool, + createRemoveResourceTool, + createAddToolTool, + createRemoveToolTool, + createAddPromptTool, + createRemovePromptTool, + createUpdateResourceTool, + createSendProgressTool, + createNumberedTools, + createSimpleTaskTool, + createProgressTaskTool, + createElicitationTaskTool, + createSamplingTaskTool, + createOptionalTaskTool, + createForbiddenTaskTool, + createImmediateReturnTaskTool, + createArchitectureResource, + createTestCwdResource, + createTestEnvResource, + createTestArgvResource, + createNumberedResources, + createFileResourceTemplate, + createUserResourceTemplate, + createNumberedResourceTemplates, + createSimplePrompt, + createArgsPrompt, + createNumberedPrompts, +} from "./test-server-fixtures.js"; + +export type PresetType = "tool" | "resource" | "resourceTemplate" | "prompt"; + +export type PresetResult = + | ToolDefinition + | TaskToolDefinition + | ResourceDefinition + | PromptDefinition + | ResourceTemplateDefinition + | (ToolDefinition | TaskToolDefinition)[] + | ResourceDefinition[] + | ResourceTemplateDefinition[] + | PromptDefinition[]; + +function resolveToolPreset( + name: string, + params?: Record, +): + | ToolDefinition + | TaskToolDefinition + | (ToolDefinition | TaskToolDefinition)[] { + const p = params ?? {}; + const get = (k: string) => p[k] as unknown; + switch (name) { + case "echo": + return createEchoTool(); + case "add": + return createAddTool(); + case "get_sum": + return createGetSumTool(); + case "write_to_stderr": + return createWriteToStderrTool(); + case "collect_sample": + return createCollectSampleTool(); + case "list_roots": + return createListRootsTool(); + case "collect_elicitation": + return createCollectFormElicitationTool(); + case "collect_url_elicitation": + return createCollectUrlElicitationTool(); + case "url_elicitation_form": + return createUrlElicitationFormTool(); + case "send_notification": + return createSendNotificationTool(); + case "get_annotated_message": + return createGetAnnotatedMessageTool(); + case "get_temp": + return createGetTempTool(); + case "add_resource": + return createAddResourceTool(); + case "remove_resource": + return createRemoveResourceTool(); + case "add_tool": + return createAddToolTool(); + case "remove_tool": + return createRemoveToolTool(); + case "add_prompt": + return createAddPromptTool(); + case "remove_prompt": + return createRemovePromptTool(); + case "update_resource": + return createUpdateResourceTool(); + case "send_progress": + return createSendProgressTool(get("name") as string | undefined); + case "numbered_tools": + return createNumberedTools(Number(get("count")) || 5); + case "simple_task": + return createSimpleTaskTool( + get("name") as string | undefined, + Number(get("delayMs")) || undefined, + ); + case "progress_task": + return createProgressTaskTool( + get("name") as string | undefined, + Number(get("delayMs")) || undefined, + Number(get("progressUnits")) || undefined, + ); + case "elicitation_task": + return createElicitationTaskTool(get("name") as string | undefined); + case "sampling_task": + return createSamplingTaskTool( + get("name") as string | undefined, + get("samplingText") as string | undefined, + ); + case "optional_task": + return createOptionalTaskTool( + get("name") as string | undefined, + Number(get("delayMs")) || undefined, + ); + case "forbidden_task": + return createForbiddenTaskTool( + get("name") as string | undefined, + Number(get("delayMs")) || undefined, + ); + case "immediate_return_task": + return createImmediateReturnTaskTool( + get("name") as string | undefined, + Number(get("delayMs")) || undefined, + ); + default: + throw new Error(`Unknown tool preset: ${name}`); + } +} + +function resolveResourcePreset( + name: string, + params?: Record, +): ResourceDefinition | ResourceDefinition[] { + const p = params ?? {}; + const get = (k: string) => p[k] as unknown; + switch (name) { + case "architecture": + return createArchitectureResource(); + case "test_cwd": + return createTestCwdResource(); + case "test_env": + return createTestEnvResource(); + case "test_argv": + return createTestArgvResource(); + case "numbered_resources": + return createNumberedResources(Number(get("count")) || 3); + default: + throw new Error(`Unknown resource preset: ${name}`); + } +} + +function resolveResourceTemplatePreset( + name: string, + params?: Record, +): ResourceTemplateDefinition | ResourceTemplateDefinition[] { + const p = params ?? {}; + const get = (k: string) => p[k] as unknown; + switch (name) { + case "file": + return createFileResourceTemplate(); + case "user": + return createUserResourceTemplate(); + case "numbered_resource_templates": + return createNumberedResourceTemplates(Number(get("count")) || 3); + default: + throw new Error(`Unknown resource template preset: ${name}`); + } +} + +function resolvePromptPreset( + name: string, + params?: Record, +): PromptDefinition | PromptDefinition[] { + const p = params ?? {}; + const get = (k: string) => p[k] as unknown; + switch (name) { + case "simple_prompt": + return createSimplePrompt(); + case "args_prompt": + return createArgsPrompt(); + case "numbered_prompts": + return createNumberedPrompts(Number(get("count")) || 3); + default: + throw new Error(`Unknown prompt preset: ${name}`); + } +} + +/** + * Resolve a preset by type and name to definition(s) + */ +export function resolvePreset( + type: PresetType, + name: string, + params?: Record, +): PresetResult { + switch (type) { + case "tool": + return resolveToolPreset(name, params); + case "resource": + return resolveResourcePreset(name, params); + case "resourceTemplate": + return resolveResourceTemplatePreset(name, params); + case "prompt": + return resolvePromptPreset(name, params); + default: + throw new Error(`Unknown preset type: ${type}`); + } +} diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts new file mode 100644 index 000000000..2269fcfc3 --- /dev/null +++ b/test-servers/src/resolve-config.ts @@ -0,0 +1,86 @@ +/** + * Resolves config file preset refs to ServerConfig for createMcpServer + */ + +import type { ServerConfig } from "./composable-test-server.js"; +import type { + ToolDefinition, + TaskToolDefinition, + ResourceDefinition, + PromptDefinition, + ResourceTemplateDefinition, +} from "./composable-test-server.js"; +import { createTestServerInfo } from "./test-server-fixtures.js"; +import { resolvePreset } from "./preset-registry.js"; +import type { ConfigFile, PresetRef } from "./load-config.js"; + +function resolvePresetRefs( + refs: Array | undefined, + type: "tool" | "resource" | "resourceTemplate" | "prompt", +): T[] { + if (!refs || refs.length === 0) return []; + const result: T[] = []; + for (const entry of refs) { + const items = Array.isArray(entry) ? entry : [entry]; + for (const ref of items) { + const presetName = ref.preset; + if (!presetName || typeof presetName !== "string") { + throw new Error( + `Invalid preset ref: preset must be a non-empty string`, + ); + } + const resolved = resolvePreset(type, presetName, ref.params); + const arr = Array.isArray(resolved) ? resolved : [resolved]; + result.push(...(arr as T[])); + } + } + return result; +} + +/** + * Resolve config file to ServerConfig for createMcpServer + */ +export function resolveConfig(config: ConfigFile): ServerConfig { + const tools = resolvePresetRefs( + config.tools, + "tool", + ); + const resources = resolvePresetRefs( + config.resources, + "resource", + ); + const resourceTemplates = resolvePresetRefs( + config.resourceTemplates, + "resourceTemplate", + ); + const prompts = resolvePresetRefs(config.prompts, "prompt"); + + const serverInfo = createTestServerInfo( + config.serverInfo.name, + config.serverInfo.version, + ); + + const transport = config.transport; + const isHttp = + transport.type === "streamable-http" || transport.type === "sse"; + + const serverConfig: ServerConfig = { + serverInfo, + tools: tools.length > 0 ? tools : undefined, + resources: resources.length > 0 ? resources : undefined, + resourceTemplates: + resourceTemplates.length > 0 ? resourceTemplates : undefined, + prompts: prompts.length > 0 ? prompts : undefined, + logging: config.logging, + listChanged: config.listChanged, + subscriptions: config.subscriptions, + tasks: config.tasks, + maxPageSize: config.maxPageSize, + serverType: isHttp + ? (transport.type as "sse" | "streamable-http") + : undefined, + port: isHttp ? transport.port : undefined, + }; + + return serverConfig; +} diff --git a/test-servers/src/server-composable.ts b/test-servers/src/server-composable.ts new file mode 100644 index 000000000..bcdc2826f --- /dev/null +++ b/test-servers/src/server-composable.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +/** + * Config-driven composable MCP test server + * Usage: server-composable --config [--json|--yaml] + */ + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { ResourceDefinition } from "./composable-test-server.js"; +import { createMcpServer } from "./test-server-fixtures.js"; +import { loadConfig, type ConfigFormat } from "./load-config.js"; +import { resolveConfig } from "./resolve-config.js"; +import { createTestServerHttp } from "./test-server-http.js"; + +function parseArgs(): { + configPath: string | null; + format: ConfigFormat | null; +} { + const args = process.argv.slice(2); + let configPath: string | null = null; + let format: ConfigFormat | null = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--config" && args[i + 1]) { + configPath = args[++i] ?? null; + } else if (args[i] === "--json") { + format = "json"; + } else if (args[i] === "--yaml") { + format = "yaml"; + } + } + + return { configPath, format }; +} + +function addStdioResourceCallback(config: ReturnType) { + return { + ...config, + onRegisterResource: (resource: ResourceDefinition) => { + if ( + resource.name === "test_cwd" || + resource.name === "test_env" || + resource.name === "test_argv" + ) { + return async () => { + let text: string; + if (resource.name === "test_cwd") { + text = process.cwd(); + } else if (resource.name === "test_env") { + text = JSON.stringify(process.env, null, 2); + } else if (resource.name === "test_argv") { + text = JSON.stringify(process.argv, null, 2); + } else { + text = (resource as { text?: string }).text ?? ""; + } + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType || "text/plain", + text, + }, + ], + }; + }; + } + return undefined; + }, + }; +} + +async function main(): Promise { + const { configPath, format } = parseArgs(); + + if (!configPath) { + console.error("Usage: server-composable --config [--json | --yaml]"); + process.exit(1); + } + + let config; + try { + config = loadConfig(configPath, format ? { format } : undefined); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + let serverConfig; + try { + serverConfig = resolveConfig(config); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + const transportType = config.transport.type; + + if (transportType === "stdio") { + const configWithCallback = addStdioResourceCallback(serverConfig); + const mcpServer = createMcpServer(configWithCallback); + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + // Process stays alive; stdio keeps it open + } else { + // HTTP (streamable-http or sse) + const httpServer = createTestServerHttp(serverConfig); + const port = await httpServer.start(); + console.error( + `Composable server listening at http://127.0.0.1:${port}${config.transport.type === "sse" ? "/sse" : "/mcp"}`, + ); + + const shutdown = async () => { + await httpServer.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/test-servers/src/test-helpers.ts b/test-servers/src/test-helpers.ts new file mode 100644 index 000000000..081d8a268 --- /dev/null +++ b/test-servers/src/test-helpers.ts @@ -0,0 +1,277 @@ +/** + * Test helpers for event-driven waits and polling. + * Use these instead of arbitrary setTimeout/setInterval in E2E tests. + */ + +import { vi } from "vitest"; +import * as fs from "node:fs/promises"; + +export interface WaitForEventOptions { + timeout?: number; +} + +/** + * Wait for a single event on an EventTarget. Resolves with the event detail, + * or rejects after `timeout` ms if the event never fires. + */ +export function waitForEvent( + target: EventTarget, + eventName: string, + options?: WaitForEventOptions, +): Promise { + const timeoutMs = options?.timeout ?? 5000; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + target.removeEventListener(eventName, handler); + reject( + new Error(`Timeout waiting for event '${eventName}' (${timeoutMs}ms)`), + ); + }, timeoutMs); + const handler = (e: Event) => { + clearTimeout(timer); + target.removeEventListener(eventName, handler); + resolve((e as CustomEvent).detail); + }; + target.addEventListener(eventName, handler); + }); +} + +export interface WaitForProgressCountOptions { + timeout?: number; +} + +/** + * Wait until `progressNotification` has been received `expectedCount` times. + * Returns the collected event details. Use for sendProgress and progress-linked-to-tasks tests. + */ +export function waitForProgressCount( + client: { + addEventListener: (type: string, fn: (e: Event) => void) => void; + removeEventListener: (type: string, fn: (e: Event) => void) => void; + }, + expectedCount: number, + options?: WaitForProgressCountOptions, +): Promise { + const timeoutMs = options?.timeout ?? 5000; + const events: unknown[] = []; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + client.removeEventListener("progressNotification", handler); + reject( + new Error( + `Timeout waiting for ${expectedCount} progressNotification events (got ${events.length}) after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + const handler = (e: Event) => { + events.push((e as CustomEvent).detail); + if (events.length >= expectedCount) { + clearTimeout(timer); + client.removeEventListener("progressNotification", handler); + resolve(events); + } + }; + client.addEventListener("progressNotification", handler); + }); +} + +export interface WaitForStateFileOptions { + timeout?: number; + interval?: number; +} + +const DEBUG_WAIT_FOR_STATE_FILE = process.env.DEBUG_WAIT_FOR_STATE_FILE === "1"; + +function truncate(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + return s.slice(0, maxLen) + `... (${s.length} chars total)`; +} + +/** + * Poll state file until `predicate(parsed)` returns true, then return the parsed value. + * Uses vi.waitFor under the hood. For use with Zustand persist state.json files. + * + * On failure, the thrown error includes: + * - Whether the failure was a JSON parse error or predicate returned false. + * - A truncated snippet of what was read (to distinguish partial write vs wrong content). + * - Attempt count (to see if we timed out early or after many retries). + * + * Run with DEBUG_WAIT_FOR_STATE_FILE=1 to log every attempt (parse ok/fail, predicate result). + */ +export async function waitForStateFile( + filePath: string, + predicate: (parsed: unknown) => boolean, + options?: WaitForStateFileOptions, +): Promise { + const { timeout = 2000, interval = 50 } = options ?? {}; + let result: T | undefined; + let attemptCount = 0; + + await vi.waitFor( + async () => { + attemptCount += 1; + let raw: string; + try { + raw = await fs.readFile(filePath, "utf-8"); + } catch (readErr) { + const msg = (readErr as NodeJS.ErrnoException).code ?? String(readErr); + if (DEBUG_WAIT_FOR_STATE_FILE) { + console.error( + `[waitForStateFile] attempt ${attemptCount} read failed:`, + msg, + ); + } + throw new Error( + `waitForStateFile failed: file read error (${msg}). File: ${filePath}. Attempts: ${attemptCount}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + if (DEBUG_WAIT_FOR_STATE_FILE) { + console.error( + `[waitForStateFile] attempt ${attemptCount} JSON parse failed. Raw (first 300):`, + truncate(raw, 300), + ); + } + throw new Error( + `waitForStateFile failed: JSON parse error (file may be mid-write or corrupt). File: ${filePath}. Attempts: ${attemptCount}. Raw snippet: ${truncate(raw, 200)}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`, + ); + } + const predOk = predicate(parsed); + if (DEBUG_WAIT_FOR_STATE_FILE) { + console.error( + `[waitForStateFile] attempt ${attemptCount} parse ok, predicate: ${predOk}`, + ); + } + if (!predOk) { + throw new Error( + `waitForStateFile failed: predicate returned false. File: ${filePath}. Attempts: ${attemptCount}. Parsed snippet: ${truncate(JSON.stringify(parsed), 200)}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`, + ); + } + result = parsed as T; + return true; + }, + { timeout, interval }, + ); + return result!; +} + +export interface WaitForOAuthWellKnownOptions { + timeout?: number; + interval?: number; + /** Max time per fetch attempt (so one hung request doesn't burn the whole timeout). Default 1000. */ + requestTimeout?: number; +} + +/** + * Poll the OAuth authorization server well-known URL until it returns 200. + * Use after server.start() and before client.authenticate() in E2E tests so + * the SDK's discovery never races with server readiness (which would cause + * it to fall back to /authorize instead of /oauth/authorize). + * + * @param serverBaseUrl - Base URL of the server (e.g. http://localhost:PORT) + */ +export async function waitForOAuthWellKnown( + serverBaseUrl: string, + options?: WaitForOAuthWellKnownOptions, +): Promise { + const { + timeout = 5000, + interval = 50, + requestTimeout = 1000, + } = options ?? {}; + const wellKnownUrl = `${serverBaseUrl.replace(/\/$/, "")}/.well-known/oauth-authorization-server`; + const start = Date.now(); + let lastStatus: number | undefined; + let lastError: unknown; + while (Date.now() - start < timeout) { + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), requestTimeout); + try { + const res = await fetch(wellKnownUrl, { signal: controller.signal }); + lastStatus = res.status; + if (res.ok) return; + } finally { + clearTimeout(t); + } + } catch (err) { + lastError = err; + // connection error or request timeout, retry + } + await new Promise((r) => setTimeout(r, interval)); + } + const statusPart = + lastStatus !== undefined + ? `lastStatus: ${lastStatus}` + : "lastStatus: (none)"; + const errorPart = + lastError !== undefined + ? `lastError: ${lastError instanceof Error ? lastError.message : String(lastError)}` + : "lastError: (none)"; + throw new Error( + `waitForOAuthWellKnown timed out after ${timeout}ms: ${wellKnownUrl}. ${statusPart}, ${errorPart}`, + ); +} + +export interface WaitForRemoteStoreOptions { + timeout?: number; + interval?: number; + /** Max time per fetch attempt. Default 1000. */ + requestTimeout?: number; +} + +/** + * Poll GET /api/storage/:storeId until the response body satisfies `predicate`. + * Use after persisting state (e.g. setServerState or client disconnect) and before + * creating a second client/store or asserting on the API, so the test doesn't race + * with async persist (Zustand setItem). + * + * Uses x-mcp-remote-auth: Bearer for the request. + * + * @param baseUrl - Remote server base URL (e.g. http://127.0.0.1:PORT) + * @param storeId - Store ID (e.g. "oauth", "test-store") + * @param authToken - Auth token for x-mcp-remote-auth header + * @param predicate - Called with parsed JSON body; return true when ready + */ +export async function waitForRemoteStore( + baseUrl: string, + storeId: string, + authToken: string, + predicate: (body: unknown) => boolean, + options?: WaitForRemoteStoreOptions, +): Promise { + const { + timeout = 3000, + interval = 50, + requestTimeout = 1000, + } = options ?? {}; + const url = `${baseUrl.replace(/\/$/, "")}/api/storage/${encodeURIComponent(storeId)}`; + const headers: Record = { + "x-mcp-remote-auth": `Bearer ${authToken}`, + }; + + await vi.waitFor( + async () => { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), requestTimeout); + try { + const res = await fetch(url, { headers, signal: controller.signal }); + if (!res.ok) { + throw new Error( + `waitForRemoteStore: GET ${url} returned ${res.status}`, + ); + } + const body: unknown = await res.json(); + if (!predicate(body)) { + throw new Error("waitForRemoteStore: predicate not yet satisfied"); + } + } finally { + clearTimeout(t); + } + }, + { timeout, interval }, + ); +} diff --git a/test-servers/src/test-server-control.ts b/test-servers/src/test-server-control.ts new file mode 100644 index 000000000..955b09811 --- /dev/null +++ b/test-servers/src/test-server-control.ts @@ -0,0 +1,19 @@ +/** + * Test-only server control for orderly shutdown. + * HTTP test server sets this when starting and clears it when stopping. + * Progress-sending tools check isClosing() before sending and skip/break if closing. + */ + +export interface ServerControl { + isClosing(): boolean; +} + +let current: ServerControl | null = null; + +export function setTestServerControl(c: ServerControl | null): void { + current = c; +} + +export function getTestServerControl(): ServerControl | null { + return current; +} diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts new file mode 100644 index 000000000..da22760d2 --- /dev/null +++ b/test-servers/src/test-server-fixtures.ts @@ -0,0 +1,1896 @@ +/** + * Shared test fixtures for composable MCP test servers + * + * This module provides helper functions for creating test tools, prompts, and resources. + * For the core composable server types and createMcpServer function, see composable-test-server.ts + */ + +import * as z from "zod/v4"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { + CreateMessageResultSchema, + CreateTaskResultSchema, + ElicitResultSchema, + GetTaskResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + ToolDefinition, + TaskToolDefinition, + ResourceDefinition, + PromptDefinition, + ResourceTemplateDefinition, + ServerConfig, + TestServerContext, +} from "./composable-test-server.js"; +import { getTestServerControl } from "./test-server-control.js"; +import type { + ElicitRequestFormParams, + ElicitRequestURLParams, +} from "@modelcontextprotocol/sdk/types.js"; +import type { + TaskRequestHandlerExtra, + CreateTaskRequestHandlerExtra, +} from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js"; +import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/sdk/types.js"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; +import type { ShapeOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import type { + GetTaskResult, + CallToolResult, + ServerRequest, + ServerNotification, +} from "@modelcontextprotocol/sdk/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js"; + +/** Build a CallToolResult from a text message (and optional isError). */ +function toToolResult(text: string, isError?: boolean): CallToolResult { + return { + content: [{ type: "text", text }], + ...(isError && { isError: true }), + }; +} + +// Re-export types and functions from composable-test-server for backward compatibility +export type { + ToolDefinition, + TaskToolDefinition, + ResourceDefinition, + PromptDefinition, + ResourceTemplateDefinition, + ServerConfig, +} from "./composable-test-server.js"; +export { createMcpServer } from "./composable-test-server.js"; + +/** + * Create multiple numbered tools for pagination testing + * @param count Number of tools to create + * @returns Array of tool definitions + */ +export function createNumberedTools(count: number): ToolDefinition[] { + const tools: ToolDefinition[] = []; + for (let i = 1; i <= count; i++) { + tools.push({ + name: `tool_${i}`, + description: `Test tool number ${i}`, + inputSchema: { + message: z.string().describe(`Message for tool ${i}`), + }, + handler: async (params: Record) => { + return toToolResult(`Tool ${i}: ${params.message as string}`); + }, + }); + } + return tools; +} + +/** + * Create multiple numbered resources for pagination testing + * @param count Number of resources to create + * @returns Array of resource definitions + */ +export function createNumberedResources(count: number): ResourceDefinition[] { + const resources: ResourceDefinition[] = []; + for (let i = 1; i <= count; i++) { + resources.push({ + name: `resource_${i}`, + uri: `test://resource_${i}`, + description: `Test resource number ${i}`, + mimeType: "text/plain", + text: `Content for resource ${i}`, + }); + } + return resources; +} + +/** + * Create multiple numbered resource templates for pagination testing + * @param count Number of resource templates to create + * @returns Array of resource template definitions + */ +export function createNumberedResourceTemplates( + count: number, +): ResourceTemplateDefinition[] { + const templates: ResourceTemplateDefinition[] = []; + for (let i = 1; i <= count; i++) { + templates.push({ + name: `template_${i}`, + uriTemplate: `test://template_${i}/{param}`, + description: `Test resource template number ${i}`, + handler: async (uri: URL, variables: Record) => { + return { + contents: [ + { + uri: uri.toString(), + mimeType: "text/plain", + text: `Content for template ${i} with param ${variables.param}`, + }, + ], + }; + }, + }); + } + return templates; +} + +/** + * Create multiple numbered prompts for pagination testing + * @param count Number of prompts to create + * @returns Array of prompt definitions + */ +export function createNumberedPrompts(count: number): PromptDefinition[] { + const prompts: PromptDefinition[] = []; + for (let i = 1; i <= count; i++) { + prompts.push({ + name: `prompt_${i}`, + description: `Test prompt number ${i}`, + promptString: `This is prompt ${i}`, + }); + } + return prompts; +} + +/** + * Create an "echo" tool that echoes back the input message + */ +export function createEchoTool(): ToolDefinition { + return { + name: "echo", + description: "Echo back the input message", + inputSchema: { + message: z.string().describe("Message to echo back"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + ) => { + return toToolResult(`Echo: ${params.message as string}`); + }, + }; +} + +/** + * Create a tool that writes a message to stderr. Used to test stderr capture/piping. + */ +export function createWriteToStderrTool(): ToolDefinition { + return { + name: "write_to_stderr", + description: "Write a message to stderr (for testing stderr capture)", + inputSchema: { + message: z.string().describe("Message to write to stderr"), + }, + handler: async (params: Record) => { + const msg = params.message as string; + process.stderr.write(`${msg}\n`); + return toToolResult(`Wrote to stderr: ${msg}`); + }, + }; +} + +/** + * Create an "add" tool that adds two numbers together + */ +export function createAddTool(): ToolDefinition { + return { + name: "add", + description: "Add two numbers together", + inputSchema: { + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + ) => { + const a = params.a as number; + const b = params.b as number; + return toToolResult(JSON.stringify({ result: a + b })); + }, + }; +} + +/** + * Create a "get_sum" tool that returns the sum of two numbers (alias for add) + */ +export function createGetSumTool(): ToolDefinition { + return { + name: "get_sum", + description: "Get the sum of two numbers", + inputSchema: { + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + ) => { + const a = params.a as number; + const b = params.b as number; + return toToolResult(JSON.stringify({ result: a + b })); + }, + }; +} + +/** + * Create a "collect_sample" tool that sends a sampling request and returns the response + */ +export function createCollectSampleTool(): ToolDefinition { + return { + name: "collect_sample", + description: + "Send a sampling request with the given text and return the response", + inputSchema: { + text: z.string().describe("Text to send in the sampling request"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + const text = params.text as string; + + // Send a sampling/createMessage request to the client using the SDK's createMessage method + try { + const result = await server.server.createMessage({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: text, + }, + }, + ], + maxTokens: 100, // Required parameter + }); + + return toToolResult(`Sampling response: ${JSON.stringify(result)}`); + } catch (error) { + console.error( + "[collect_sample] Error sending/receiving sampling request:", + error, + ); + throw error; + } + }, + }; +} + +/** + * Create a "list_roots" tool that calls roots/list and returns the roots + */ +export function createListRootsTool(): ToolDefinition { + return { + name: "list_roots", + description: "List the current roots configured on the client", + inputSchema: {}, + handler: async ( + _params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + try { + // Call roots/list on the client using the SDK's listRoots method + const result = await server.server.listRoots(); + + return toToolResult(`Roots: ${JSON.stringify(result.roots, null, 2)}`); + } catch (error) { + return toToolResult( + `Error listing roots: ${error instanceof Error ? error.message : String(error)}`, + true, + ); + } + }, + }; +} + +/** + * Create a "collectElicitation" tool that sends an elicitation request and returns the response + */ +export function createCollectFormElicitationTool(): ToolDefinition { + return { + name: "collect_elicitation", + description: + "Send an elicitation request with the given message and schema and return the response", + inputSchema: { + message: z + .string() + .describe("Message to send in the elicitation request"), + schema: z.unknown().describe("JSON schema for the elicitation request"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + const message = params.message as string; + const schema = + params.schema as ElicitRequestFormParams["requestedSchema"]; + + // Send a form-based elicitation request using the SDK's elicitInput method + try { + const elicitationParams: ElicitRequestFormParams = { + message, + requestedSchema: schema, + }; + + const result = await server.server.elicitInput(elicitationParams); + + return toToolResult(`Elicitation response: ${JSON.stringify(result)}`); + } catch (error) { + console.error( + "[collectElicitation] Error sending/receiving elicitation request:", + error, + ); + throw error; + } + }, + }; +} + +/** + * Create a "url_elicitation_form" tool that spins up a simple HTTP server on a dynamic + * port with a form page, sends that URL via URL elicitation, and on form submit collects + * the text input, includes it in the tool response, and closes the server. + */ +export function createUrlElicitationFormTool(): ToolDefinition { + return { + name: "url_elicitation_form", + description: + "Present a form via URL elicitation; collects submitted text and returns it in the tool response", + inputSchema: { + message: z + .string() + .optional() + .describe( + "Message to show in the elicitation (default: prompt for input)", + ), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + const message = + (params.message as string) || "Please submit a value in the form"; + + const elicitationId = `url-form-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + let resolveFormData!: (value: string) => void; + const formDataPromise = new Promise((resolve) => { + resolveFormData = resolve; + }); + + const completionNotifier = + server.server.createElicitationCompletionNotifier(elicitationId); + + const { createServer } = await import("node:http"); + const { createServer: createNetServer } = await import("node:net"); + + const formHtml = (elicitationId: string) => ` + + +Submit Value + +
+ + + +
+ +`; + + const successHtml = ` + + +Submitted +

Submitted. You can close this window.

+`; + + const httpServer = createServer((req, res) => { + if (req.method === "GET" && req.url === "/") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(formHtml(elicitationId)); + return; + } + if (req.method === "POST" && req.url === "/") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + const params = new URLSearchParams(body); + const value = params.get("value") ?? ""; + completionNotifier().catch(() => {}); + resolveFormData(value); + httpServer.close(); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(successHtml); + }); + return; + } + res.writeHead(404); + res.end(); + }); + + const port = await new Promise((resolve, reject) => { + const s = createNetServer(); + s.listen(0, "127.0.0.1", () => { + const addr = s.address() as { port: number }; + s.close(() => resolve(addr.port)); + }); + s.on("error", reject); + }); + + httpServer.listen(port, "127.0.0.1"); + const url = `http://127.0.0.1:${port}/`; + + try { + const result = await server.server.elicitInput({ + mode: "url", + message, + elicitationId, + url, + }); + + if (result.action !== "accept") { + httpServer.close(); + return toToolResult( + `Elicitation ${result.action}: user did not accept`, + ); + } + + const collectedValue = await formDataPromise; + return toToolResult(`Collected value: ${collectedValue}`); + } catch (error) { + httpServer.close(); + throw error; + } + }, + }; +} + +/** + * Create a "collect_url_elicitation" tool that sends a URL-based elicitation request + * to the client and returns the response + */ +export function createCollectUrlElicitationTool(): ToolDefinition { + return { + name: "collect_url_elicitation", + description: + "Send a URL-based elicitation request with the given message and URL and return the response", + inputSchema: { + message: z + .string() + .describe("Message to send in the elicitation request"), + url: z.string().url().describe("URL for the user to navigate to"), + elicitationId: z + .string() + .optional() + .describe("Optional elicitation ID (generated if not provided)"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + const message = params.message as string; + const url = params.url as string; + const elicitationId = + (params.elicitationId as string) || + `url-elicitation-${Date.now()}-${Math.random()}`; + + // Send a URL-based elicitation request using the SDK's elicitInput method + try { + const elicitationParams: ElicitRequestURLParams = { + mode: "url", + message, + elicitationId, + url, + }; + + const result = await server.server.elicitInput(elicitationParams); + + return toToolResult( + `URL elicitation response: ${JSON.stringify(result)}`, + ); + } catch (error) { + console.error( + "[collect_url_elicitation] Error sending/receiving URL elicitation request:", + error, + ); + throw error; + } + }, + }; +} + +/** + * Create a "send_notification" tool that sends a notification message from the server + */ +export function createSendNotificationTool(): ToolDefinition { + return { + name: "send_notification", + description: "Send a notification message from the server", + inputSchema: { + message: z.string().describe("Notification message to send"), + level: z + .enum([ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency", + ]) + .optional() + .describe("Log level for the notification"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + const message = params.message as string; + const level = (params.level as string) || "info"; + + // Send a notification from the server + // Notifications don't have an id and use the jsonrpc format + try { + await server.server.notification({ + method: "notifications/message", + params: { + level, + logger: "test-server", + data: { + message, + }, + }, + }); + + return toToolResult(`Notification sent: ${message}`); + } catch (error) { + console.error("[send_notification] Error sending notification:", error); + throw error; + } + }, + }; +} + +/** + * Create a "get-annotated-message" tool that returns a message with optional image + */ +export function createGetAnnotatedMessageTool(): ToolDefinition { + return { + name: "get_annotated_message", + description: "Get an annotated message", + inputSchema: { + messageType: z + .enum(["success", "error", "warning", "info"]) + .describe("Type of message"), + includeImage: z + .boolean() + .optional() + .describe("Whether to include an image"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + ): Promise => { + const messageType = params.messageType as string; + const includeImage = params.includeImage as boolean | undefined; + const message = `This is a ${messageType} message`; + const content: Array< + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + > = [ + { + type: "text", + text: message, + }, + ]; + + if (includeImage) { + content.push({ + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG + mimeType: "image/png", + }); + } + + return { content }; + }, + }; +} + +/** Output schema for get_temp: temperature, unit, city */ +const GetTempOutputSchema = z.object({ + temperature: z.number().describe("Temperature value"), + unit: z.string().describe("C or F"), + city: z.string().describe("City name"), +}); + +/** + * Create a "get_temp" tool that returns both content (human-readable) and structuredContent (schema-validated). + * Takes city and units (C/F), returns mock temperature 25 and matching text + structured output. + */ +export function createGetTempTool(): ToolDefinition { + return { + name: "get_temp", + description: + "Get the current temperature for a city (mock; returns 25 in requested units)", + inputSchema: { + city: z.string().describe("City name"), + units: z.enum(["C", "F"]).describe("Temperature units"), + }, + outputSchema: GetTempOutputSchema, + handler: async (params: Record) => { + const city = (params.city as string) || "Unknown"; + const unit = (params.units as "C" | "F") || "C"; + const temperature = 25; + const text = `The temperature in ${city} is ${temperature} degrees ${unit}`; + return { + content: [{ type: "text" as const, text }], + structuredContent: { temperature, unit, city }, + }; + }, + }; +} + +/** + * Create a "simple_prompt" prompt definition + */ +export function createSimplePrompt(): PromptDefinition { + return { + name: "simple_prompt", + description: "A simple prompt for testing", + promptString: "This is a simple prompt for testing purposes.", + }; +} + +/** + * Create an "args_prompt" prompt that accepts arguments + */ +export function createArgsPrompt( + completions?: Record< + string, + ( + argumentValue: string, + context?: Record, + ) => Promise | string[] + >, +): PromptDefinition { + return { + name: "args_prompt", + description: "A prompt that accepts arguments for testing", + promptString: "This is a prompt with arguments: city={city}, state={state}", + argsSchema: { + city: z.string().describe("City name"), + state: z.string().describe("State name"), + }, + completions, + }; +} + +/** + * Create an "architecture" resource definition + */ +export function createArchitectureResource(): ResourceDefinition { + return { + name: "architecture", + uri: "demo://resource/static/document/architecture.md", + description: "Architecture documentation", + mimeType: "text/markdown", + text: `# Architecture Documentation + +This is a test resource for the MCP test server. + +## Overview + +This resource is used for testing resource reading functionality in the CLI. + +## Sections + +- Introduction +- Design +- Implementation +- Testing + +## Notes + +This is a static resource provided by the test MCP server. +`, + }; +} + +/** + * Create a "test_cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server) + */ +export function createTestCwdResource(): ResourceDefinition { + return { + name: "test_cwd", + uri: "test://cwd", + description: "Current working directory of the test server", + mimeType: "text/plain", + text: process.cwd(), + }; +} + +/** + * Create a "test_env" resource that exposes environment variables (generally useful when testing with the stdio test server) + */ +export function createTestEnvResource(): ResourceDefinition { + return { + name: "test_env", + uri: "test://env", + description: "Environment variables available to the test server", + mimeType: "application/json", + text: JSON.stringify(process.env, null, 2), + }; +} + +/** + * Create a "test_argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server) + */ +export function createTestArgvResource(): ResourceDefinition { + return { + name: "test_argv", + uri: "test://argv", + description: "Command-line arguments the test server was started with", + mimeType: "application/json", + text: JSON.stringify(process.argv, null, 2), + }; +} + +/** + * Create minimal server info for test servers + */ +export function createTestServerInfo( + name: string = "test-server", + version: string = "1.0.0", +): Implementation { + return { + name, + version, + }; +} + +/** + * Create a "file" resource template that reads files by path + */ +export function createFileResourceTemplate( + completionCallback?: ( + argumentName: string, + value: string, + context?: Record, + ) => Promise | string[], + listCallback?: () => Promise | string[], +): ResourceTemplateDefinition { + return { + name: "file", + uriTemplate: "file:///{path}", + description: "Read a file by path", + inputSchema: { + path: z.string().describe("File path to read"), + }, + handler: async (uri: URL, params: Record) => { + const path = params.path as string; + // For testing, return a mock file content + return { + contents: [ + { + uri: uri.toString(), + mimeType: "text/plain", + text: `Mock file content for: ${path}\nThis is a test resource template.`, + }, + ], + }; + }, + complete: completionCallback, + list: listCallback, + }; +} + +/** + * Create a "user" resource template that returns user data by ID + */ +export function createUserResourceTemplate( + completionCallback?: ( + argumentName: string, + value: string, + context?: Record, + ) => Promise | string[], + listCallback?: () => Promise | string[], +): ResourceTemplateDefinition { + return { + name: "user", + uriTemplate: "user://{userId}", + description: "Get user data by ID", + inputSchema: { + userId: z.string().describe("User ID"), + }, + handler: async (uri: URL, params: Record) => { + const userId = params.userId as string; + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify( + { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + role: "test-user", + }, + null, + 2, + ), + }, + ], + }; + }, + complete: completionCallback, + list: listCallback, + }; +} + +/** + * Create a tool that adds a resource to the server and sends list_changed notification + */ +export function createAddResourceTool(): ToolDefinition { + return { + name: "add_resource", + description: + "Add a resource to the server and send list_changed notification", + inputSchema: { + uri: z.string().describe("Resource URI"), + name: z.string().describe("Resource name"), + description: z.string().optional().describe("Resource description"), + mimeType: z.string().optional().describe("Resource MIME type"), + text: z.string().optional().describe("Resource text content"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Register with SDK (returns RegisteredResource) + const registered = server.registerResource( + params.name as string, + params.uri as string, + { + description: params.description as string | undefined, + mimeType: params.mimeType as string | undefined, + }, + async () => { + return { + contents: params.text + ? [ + { + uri: params.uri as string, + mimeType: params.mimeType as string | undefined, + text: params.text as string, + }, + ] + : [], + }; + }, + ); + + // Track in state (keyed by URI) + state.registeredResources.set(params.uri as string, registered); + + // Send notification if capability enabled + if (state.listChangedConfig.resources) { + server.sendResourceListChanged(); + } + + return toToolResult(`Resource ${params.uri} added`); + }, + }; +} + +/** + * Create a tool that removes a resource from the server by URI and sends list_changed notification + */ +export function createRemoveResourceTool(): ToolDefinition { + return { + name: "remove_resource", + description: + "Remove a resource from the server by URI and send list_changed notification", + inputSchema: { + uri: z.string().describe("Resource URI to remove"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Find registered resource by URI + const resource = state.registeredResources.get(params.uri as string); + if (!resource) { + throw new Error(`Resource with URI ${params.uri} not found`); + } + + // Remove from SDK registry + resource.remove(); + + // Remove from tracking + state.registeredResources.delete(params.uri as string); + + // Send notification if capability enabled + if (state.listChangedConfig.resources) { + server.sendResourceListChanged(); + } + + return toToolResult(`Resource ${params.uri} removed`); + }, + }; +} + +/** + * Create a tool that adds a tool to the server and sends list_changed notification + */ +export function createAddToolTool(): ToolDefinition { + return { + name: "add_tool", + description: "Add a tool to the server and send list_changed notification", + inputSchema: { + name: z.string().describe("Tool name"), + description: z.string().describe("Tool description"), + inputSchema: z.unknown().optional().describe("Tool input schema"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Register with SDK (returns RegisteredTool) + const registered = server.registerTool( + params.name as string, + { + description: params.description as string, + inputSchema: params.inputSchema as ZodRawShapeCompat | undefined, + }, + async () => { + return { + content: [ + { + type: "text" as const, + text: `Tool ${params.name} executed`, + }, + ], + }; + }, + ); + + // Track in state (keyed by name) + state.registeredTools.set(params.name as string, registered); + + // Send notification if capability enabled + // Note: sendToolListChanged() is synchronous on McpServer but internally calls async Server method + // We don't await it, but the tool should be registered before sending the notification + if (state.listChangedConfig.tools) { + // Small delay to ensure tool is fully registered in SDK's internal state + await new Promise((resolve) => setTimeout(resolve, 10)); + server.sendToolListChanged(); + } + + return toToolResult(`Tool ${params.name} added`); + }, + }; +} + +/** + * Create a tool that removes a tool from the server by name and sends list_changed notification + */ +export function createRemoveToolTool(): ToolDefinition { + return { + name: "remove_tool", + description: + "Remove a tool from the server by name and send list_changed notification", + inputSchema: { + name: z.string().describe("Tool name to remove"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Find registered tool by name + const tool = state.registeredTools.get(params.name as string); + if (!tool) { + throw new Error(`Tool ${params.name} not found`); + } + + // Remove from SDK registry + tool.remove(); + + // Remove from tracking + state.registeredTools.delete(params.name as string); + + // Send notification if capability enabled + if (state.listChangedConfig.tools) { + server.sendToolListChanged(); + } + + return toToolResult(`Tool ${params.name} removed`); + }, + }; +} + +/** + * Create a tool that adds a prompt to the server and sends list_changed notification + */ +export function createAddPromptTool(): ToolDefinition { + return { + name: "add_prompt", + description: + "Add a prompt to the server and send list_changed notification", + inputSchema: { + name: z.string().describe("Prompt name"), + description: z.string().optional().describe("Prompt description"), + promptString: z.string().describe("Prompt text"), + argsSchema: z.unknown().optional().describe("Prompt arguments schema"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Register with SDK (returns RegisteredPrompt) + const registered = server.registerPrompt( + params.name as string, + { + description: params.description as string | undefined, + argsSchema: params.argsSchema as ZodRawShapeCompat | undefined, + }, + async () => { + return { + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: params.promptString as string, + }, + }, + ], + }; + }, + ); + + // Track in state (keyed by name) + state.registeredPrompts.set(params.name as string, registered); + + // Send notification if capability enabled + if (state.listChangedConfig.prompts) { + server.sendPromptListChanged(); + } + + return toToolResult(`Prompt ${params.name} added`); + }, + }; +} + +/** + * Create a tool that updates an existing resource's content and sends resource updated notification + */ +export function createUpdateResourceTool(): ToolDefinition { + return { + name: "update_resource", + description: + "Update an existing resource's content and send resource updated notification", + inputSchema: { + uri: z.string().describe("Resource URI to update"), + text: z.string().describe("New resource text content"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Find registered resource by URI + const resource = state.registeredResources.get(params.uri as string); + if (!resource) { + throw new Error(`Resource with URI ${params.uri} not found`); + } + + // Get the current resource metadata to preserve mimeType + const currentResource = state.registeredResources.get( + params.uri as string, + ); + const mimeType = currentResource?.metadata?.mimeType || "text/plain"; + + // Update the resource's callback to return new content + resource.update({ + callback: async () => { + return { + contents: [ + { + uri: params.uri as string, + mimeType, + text: params.text as string, + }, + ], + }; + }, + }); + + // Send resource updated notification only if subscribed + const uri = params.uri as string; + if (state.resourceSubscriptions.has(uri)) { + await server.server.sendResourceUpdated({ + uri, + }); + } + + return toToolResult(`Resource ${params.uri} updated`); + }, + }; +} + +/** + * Create a tool that sends progress notifications during execution + * @param name Tool name (default: "send_progress") + * @returns Tool definition + */ +export function createSendProgressTool( + name: string = "send_progress", +): ToolDefinition { + return { + name, + description: + "Send progress notifications during tool execution, then return a result", + inputSchema: { + units: z + .number() + .int() + .positive() + .describe("Number of progress units to send"), + delayMs: z + .number() + .int() + .nonnegative() + .default(100) + .describe("Delay in milliseconds between progress notifications"), + total: z + .number() + .int() + .positive() + .optional() + .describe("Total number of units (for percentage calculation)"), + message: z + .string() + .optional() + .describe("Progress message to include in notifications"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + extra?: RequestHandlerExtra, + ): Promise => { + if (!context) { + throw new Error("Server context not available"); + } + const server = context.server; + + const units = params.units as number; + const delayMs = (params.delayMs as number) || 100; + const total = params.total as number | undefined; + const message = (params.message as string) || "Processing..."; + + // Extract progressToken from metadata + const progressToken = extra?._meta?.progressToken; + + // Send progress notifications + let sent = 0; + for (let i = 1; i <= units; i++) { + if (context.serverControl?.isClosing()) { + break; + } + // Wait before sending notification (except for the first one) + if (i > 1 && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + if (context.serverControl?.isClosing()) { + break; + } + + if (progressToken !== undefined) { + const progressParams: { + progress: number; + total?: number; + message?: string; + progressToken: string | number; + } = { + progress: i, + message: `${message} (${i}/${units})`, + progressToken, + }; + if (total !== undefined) { + progressParams.total = total; + } + + try { + await server.server.notification( + { + method: "notifications/progress", + params: progressParams, + }, + { relatedRequestId: extra?.requestId }, + ); + sent = i; + } catch (error) { + console.error( + "[send_progress] Error sending progress notification:", + error, + ); + break; + } + } + } + + return toToolResult( + `Completed ${sent} progress notifications (units: ${sent}, total: ${total ?? units})`, + ); + }, + }; +} + +export function createRemovePromptTool(): ToolDefinition { + return { + name: "remove_prompt", + description: + "Remove a prompt from the server by name and send list_changed notification", + inputSchema: { + name: z.string().describe("Prompt name to remove"), + }, + handler: async ( + params: Record, + context?: TestServerContext, + ) => { + if (!context) { + throw new Error("Server context not available"); + } + + const { server, state } = context; + + // Find registered prompt by name + const prompt = state.registeredPrompts.get(params.name as string); + if (!prompt) { + throw new Error(`Prompt ${params.name} not found`); + } + + // Remove from SDK registry + prompt.remove(); + + // Remove from tracking + state.registeredPrompts.delete(params.name as string); + + // Send notification if capability enabled + if (state.listChangedConfig.prompts) { + server.sendPromptListChanged(); + } + + return toToolResult(`Prompt ${params.name} removed`); + }, + }; +} + +/** Options for creating an immediate (non-task) tool that completes after a delay */ +export interface ImmediateToolOptions { + name?: string; // default: "flexibleTask" + delayMs?: number; // default: 1000 +} + +/** Options for creating a task tool (createTask + getTask + getTaskResult) with optional progress, elicitation, sampling, etc. */ +export interface TaskToolOptions { + name?: string; // default: "flexibleTask" + taskSupport?: "required" | "optional"; // default: "required" + delayMs?: number; // default: 1000 (time before task completes) + progressUnits?: number; // If provided, send progress notifications + elicitationSchema?: z.ZodTypeAny; // If provided, require elicitation with this schema + samplingText?: string; // If provided, require sampling with this text + failAfterDelay?: number; // If set, task fails after this delay (ms) + cancelAfterDelay?: number; // If set, task cancels itself after this delay (ms) + /** If set, send params.task: { ttl } so the client creates a receiver task and returns { task } immediately */ + receiverTaskTtl?: number; +} + +/** Payload we receive from the client via tasks/result when using receiver-task mode */ +interface ReceiverTaskPayload { + content: unknown; + isElicit?: boolean; +} + +/** + * Poll the client for a receiver task until terminal, then fetch tasks/result. + * Used when the server sent a create (elicitation or sampling) with params.task and got { task } back. + */ +async function pollReceiverTaskPayload( + extra: CreateTaskRequestHandlerExtra, + clientTaskId: string, + resultSchema: z.ZodTypeAny, + isElicit: boolean, +): Promise { + for (let i = 0; i < 50; i++) { + if (getTestServerControl()?.isClosing()) break; + const getRes = await extra.sendRequest( + { method: "tasks/get", params: { taskId: clientTaskId } }, + GetTaskResultSchema, + ); + const status = (getRes as { status: string }).status; + if ( + status === "completed" || + status === "failed" || + status === "cancelled" + ) { + if (status === "completed") { + try { + const payload = await extra.sendRequest( + { method: "tasks/result", params: { taskId: clientTaskId } }, + resultSchema, + ); + return { + content: (payload as { content?: unknown }).content, + isElicit: isElicit ? true : undefined, + }; + } catch { + // tasks/result may fail if task failed + } + } + break; + } + await new Promise((r) => setTimeout(r, 100)); + } + return null; +} + +/** Params for the async task execution runner used by the task tool */ +interface RunTaskExecutionParams { + task: { taskId: string }; + extra: CreateTaskRequestHandlerExtra; + message?: string; + progressToken?: string | number; + options: TaskToolOptions; +} + +/** + * Runs the task execution (input phase, progress, delay, fail/cancel, completion). + * Invoked fire-and-forget from createTask after creating the task. + */ +async function runTaskExecution(params: RunTaskExecutionParams): Promise { + const { task, extra, message, progressToken, options } = params; + const { + delayMs = 1000, + progressUnits, + elicitationSchema, + samplingText, + failAfterDelay, + cancelAfterDelay, + receiverTaskTtl, + } = options; + + let receiverTaskPayload: ReceiverTaskPayload | null = null; + + try { + // --- Input phase: elicitation or sampling (optional receiver-task polling) --- + if (elicitationSchema) { + await extra.taskStore.updateTaskStatus(task.taskId, "input_required"); + try { + const jsonSchema = toJsonSchemaCompat( + elicitationSchema, + ) as ElicitRequestFormParams["requestedSchema"]; + const elicitationParams: ElicitRequestFormParams = { + message: `Please provide input for task ${task.taskId}`, + requestedSchema: jsonSchema, + _meta: { + [RELATED_TASK_META_KEY]: { taskId: task.taskId }, + }, + ...(receiverTaskTtl != null && { task: { ttl: receiverTaskTtl } }), + }; + const elicitResponse = await extra.sendRequest( + { + method: "elicitation/create", + params: elicitationParams, + }, + (receiverTaskTtl != null + ? z.union([ElicitResultSchema, CreateTaskResultSchema]) + : ElicitResultSchema) as typeof ElicitResultSchema, + ); + const elicitWithTask = elicitResponse as unknown as { + task?: { taskId: string }; + }; + if (receiverTaskTtl != null && elicitWithTask?.task) { + receiverTaskPayload = + (await pollReceiverTaskPayload( + extra, + elicitWithTask.task.taskId, + ElicitResultSchema, + true, + )) ?? null; + } + await extra.taskStore.updateTaskStatus(task.taskId, "working"); + } catch (error) { + console.error("[flexibleTask] Elicitation error:", error); + await extra.taskStore.updateTaskStatus( + task.taskId, + "failed", + error instanceof Error ? error.message : String(error), + ); + return; + } + } + + if (samplingText) { + await extra.taskStore.updateTaskStatus(task.taskId, "input_required"); + try { + const samplingResponse = await extra.sendRequest( + { + method: "sampling/createMessage", + params: { + messages: [ + { + role: "user", + content: { type: "text", text: samplingText }, + }, + ], + maxTokens: 100, + _meta: { + [RELATED_TASK_META_KEY]: { taskId: task.taskId }, + }, + ...(receiverTaskTtl != null && { + task: { ttl: receiverTaskTtl }, + }), + }, + }, + (receiverTaskTtl != null + ? z.union([CreateMessageResultSchema, CreateTaskResultSchema]) + : CreateMessageResultSchema) as typeof CreateMessageResultSchema, + ); + const samplingWithTask = samplingResponse as unknown as { + task?: { taskId: string }; + }; + if (receiverTaskTtl != null && samplingWithTask?.task) { + receiverTaskPayload = + (await pollReceiverTaskPayload( + extra, + samplingWithTask.task.taskId, + CreateMessageResultSchema, + false, + )) ?? null; + } + await extra.taskStore.updateTaskStatus(task.taskId, "working"); + } catch (error) { + console.error("[flexibleTask] Sampling error:", error); + await extra.taskStore.updateTaskStatus( + task.taskId, + "failed", + error instanceof Error ? error.message : String(error), + ); + return; + } + } + + // --- Progress or delay --- + if ( + progressUnits !== undefined && + progressUnits > 0 && + progressToken !== undefined + ) { + for (let i = 1; i <= progressUnits; i++) { + if (getTestServerControl()?.isClosing()) break; + await new Promise((resolve) => + setTimeout(resolve, delayMs / progressUnits), + ); + if (getTestServerControl()?.isClosing()) break; + try { + await extra.sendNotification({ + method: "notifications/progress", + params: { + progress: i, + total: progressUnits, + message: `Processing... ${i}/${progressUnits}`, + progressToken, + _meta: { + [RELATED_TASK_META_KEY]: { taskId: task.taskId }, + }, + }, + }); + } catch (error) { + console.error("[flexibleTask] Progress notification error:", error); + break; + } + } + } else { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // --- Optional fail/cancel --- + if (failAfterDelay !== undefined) { + await new Promise((resolve) => setTimeout(resolve, failAfterDelay)); + await extra.taskStore.updateTaskStatus( + task.taskId, + "failed", + "Task failed as configured", + ); + return; + } + if (cancelAfterDelay !== undefined) { + await new Promise((resolve) => setTimeout(resolve, cancelAfterDelay)); + await extra.taskStore.updateTaskStatus(task.taskId, "cancelled"); + return; + } + + // --- Complete with stored or default result --- + const result = + receiverTaskPayload?.content != null + ? receiverTaskPayload.isElicit + ? { + content: [ + { + type: "text" as const, + text: JSON.stringify(receiverTaskPayload.content), + }, + ], + } + : { + content: Array.isArray(receiverTaskPayload.content) + ? receiverTaskPayload.content + : [receiverTaskPayload.content], + } + : { + content: [ + { + type: "text", + text: JSON.stringify({ + message: `Task completed: ${message || "no message"}`, + taskId: task.taskId, + }), + }, + ], + }; + await extra.taskStore.storeTaskResult(task.taskId, "completed", result); + await extra.taskStore.updateTaskStatus(task.taskId, "completed"); + } catch (error) { + try { + const currentTask = await extra.taskStore.getTask(task.taskId); + if ( + currentTask && + currentTask.status !== "completed" && + currentTask.status !== "failed" && + currentTask.status !== "cancelled" + ) { + await extra.taskStore.updateTaskStatus( + task.taskId, + "failed", + error instanceof Error ? error.message : String(error), + ); + } + } catch (statusError) { + console.error( + "[flexibleTask] Error checking/updating task status:", + statusError, + ); + } + } +} + +/** Creates an immediate (non-task) tool that completes after a delay. */ +export function createImmediateTool( + options: ImmediateToolOptions = {}, +): ToolDefinition { + const { name = "flexibleTask", delayMs = 1000 } = options; + return { + name, + description: "A tool that completes immediately without creating a task", + inputSchema: { + message: z.string().optional().describe("Optional message parameter"), + }, + handler: async ( + params: Record, + _context?: TestServerContext, + ): Promise => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return toToolResult( + `Task completed immediately: ${params.message ?? "no message"}`, + ); + }, + }; +} + +/** Creates a task tool (createTask + getTask + getTaskResult) with optional progress, elicitation, sampling, etc. */ +export function createTaskTool( + options: TaskToolOptions = {}, +): TaskToolDefinition { + const { name = "flexibleTask", taskSupport = "required" } = options; + return { + name, + description: `A flexible task tool supporting progress, elicitation, and sampling`, + inputSchema: { + message: z.string().optional().describe("Optional message parameter"), + }, + execution: { + taskSupport: taskSupport as "required" | "optional", + }, + handler: { + createTask: async (args, extra) => { + const message = (args as Record)?.message as + | string + | undefined; + const progressToken = extra._meta?.progressToken; + const task = await extra.taskStore.createTask({}); + runTaskExecution({ + task, + extra, + message, + progressToken, + options, + }).catch(() => {}); + return { task }; + }, + getTask: async ( + _args: ShapeOutput<{ message?: z.ZodString }>, + extra: TaskRequestHandlerExtra, + ): Promise => { + const task = await extra.taskStore.getTask(extra.taskId); + return task as GetTaskResult; + }, + getTaskResult: async ( + _args: ShapeOutput<{ message?: z.ZodString }>, + extra: TaskRequestHandlerExtra, + ): Promise => { + const result = await extra.taskStore.getTaskResult(extra.taskId); + if (!result.content) { + throw new Error("Task result does not have content field"); + } + return result as CallToolResult; + }, + }, + }; +} + +/** + * Create a simple task tool that completes after a delay + */ +export function createSimpleTaskTool( + name: string = "simple_task", + delayMs: number = 1000, +): TaskToolDefinition { + return createTaskTool({ name, delayMs }); +} + +/** + * Create a task tool that sends progress notifications + */ +export function createProgressTaskTool( + name: string = "progress_task", + delayMs: number = 2000, + progressUnits: number = 5, +): TaskToolDefinition { + return createTaskTool({ name, delayMs, progressUnits }); +} + +/** + * Create a task tool that requires elicitation input + */ +export function createElicitationTaskTool( + name: string = "elicitation_task", + elicitationSchema?: z.ZodTypeAny, +): TaskToolDefinition { + return createTaskTool({ + name, + elicitationSchema: + elicitationSchema || + z.object({ + input: z.string().describe("User input required for task"), + }), + }); +} + +/** + * Create a task tool that requires sampling input + */ +export function createSamplingTaskTool( + name: string = "sampling_task", + samplingText?: string, +): TaskToolDefinition { + return createTaskTool({ + name, + samplingText: samplingText || "Please provide a response for this task", + }); +} + +/** + * Create a task tool with optional task support + */ +export function createOptionalTaskTool( + name: string = "optional_task", + delayMs: number = 500, +): TaskToolDefinition { + return createTaskTool({ name, taskSupport: "optional", delayMs }); +} + +/** + * Create a tool that does not support tasks (completes immediately without creating a task) + */ +export function createForbiddenTaskTool( + name: string = "forbidden_task", + delayMs: number = 100, +): ToolDefinition { + return createImmediateTool({ name, delayMs }); +} + +/** + * Create a tool that returns immediately without creating a task + * (for testing callTool() with task-supporting server config where the tool itself is immediate) + */ +export function createImmediateReturnTaskTool( + name: string = "immediate_return_task", + delayMs: number = 100, +): ToolDefinition { + return createImmediateTool({ name, delayMs }); +} + +/** + * Get a server config with task support and task tools for testing + */ +export function getTaskServerConfig(): ServerConfig { + return { + serverInfo: createTestServerInfo("test-task-server", "1.0.0"), + tasks: { + list: true, + cancel: true, + }, + tools: [ + createSimpleTaskTool(), + createProgressTaskTool(), + createElicitationTaskTool(), + createSamplingTaskTool(), + createOptionalTaskTool(), + createForbiddenTaskTool(), + createImmediateReturnTaskTool(), + ], + logging: true, // Required for notifications/message and progress + }; +} + +/** + * Get default server config with common test tools, prompts, and resources + */ +export function getDefaultServerConfig(): ServerConfig { + return { + serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"), + tools: [ + createEchoTool(), + createGetSumTool(), + createGetAnnotatedMessageTool(), + createGetTempTool(), + createSendNotificationTool(), + createWriteToStderrTool(), + ], + prompts: [createSimplePrompt(), createArgsPrompt()], + resources: [ + createArchitectureResource(), + createTestCwdResource(), + createTestEnvResource(), + createTestArgvResource(), + ], + resourceTemplates: [ + createFileResourceTemplate(), + createUserResourceTemplate(), + ], + logging: true, // Required for notifications/message + }; +} + +/** + * OAuth Test Fixtures + */ + +/** + * Creates a test server configuration with OAuth enabled + */ +export function createOAuthTestServerConfig(options: { + requireAuth?: boolean; + scopesSupported?: string[]; + staticClients?: Array<{ + clientId: string; + clientSecret?: string; + redirectUris?: string[]; + }>; + supportDCR?: boolean; + supportCIMD?: boolean; + tokenExpirationSeconds?: number; + supportRefreshTokens?: boolean; +}): Partial { + return { + oauth: { + enabled: true, + requireAuth: options.requireAuth ?? false, + scopesSupported: options.scopesSupported ?? ["mcp"], + staticClients: options.staticClients, + supportDCR: options.supportDCR ?? false, + supportCIMD: options.supportCIMD ?? false, + tokenExpirationSeconds: options.tokenExpirationSeconds ?? 3600, + supportRefreshTokens: options.supportRefreshTokens ?? true, + }, + }; +} diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts new file mode 100644 index 000000000..b85f368cc --- /dev/null +++ b/test-servers/src/test-server-http.ts @@ -0,0 +1,507 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpServer } from "./test-server-fixtures.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.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 * as crypto from "node:crypto"; +import type { ServerConfig } from "./test-server-fixtures.js"; +import { + setupOAuthRoutes, + createBearerTokenMiddleware, +} from "./test-server-oauth.js"; +import { + setTestServerControl, + type ServerControl, +} from "./test-server-control.js"; + +export interface RecordedRequest { + method: string; + params?: Record; + headers?: Record; + metadata?: Record; + response: unknown; + 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, "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); + } else { + reject(err); + } + }); + }); +} + +/** + * Extract headers from Express request + */ +function extractHeaders(req: Request): Record { + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key] = value; + } else if (Array.isArray(value) && value.length > 0) { + const lastValue = value[value.length - 1]; + if (typeof lastValue === "string") { + headers[key] = lastValue; + } + } + } + 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 config: ServerConfig; + private readonly configWithCallback: ServerConfig; + private readonly serverControl: ServerControl; + private _closing = false; + private recordedRequests: RecordedRequest[] = []; + private httpServer?: HttpServer; + private transport?: StreamableHTTPServerTransport | SSEServerTransport; + private baseUrl?: string; + private currentRequestHeaders?: Record; + private currentLogLevel: string | null = null; + /** One McpServer per connection (SSE and streamable-http both use this; SDK allows only one transport per server) */ + private mcpServersBySession?: Map; + + constructor(config: ServerConfig) { + this.config = config; + this.serverControl = { + isClosing: () => this._closing, + }; + this.configWithCallback = { + ...config, + onLogLevelSet: (level: string) => { + this.currentLogLevel = level; + }, + serverControl: this.serverControl, + }; + } + + /** + * Set up message interception for a transport to record incoming messages + * This wraps the transport's onmessage handler to record requests/notifications + */ + private setupMessageInterception( + transport: StreamableHTTPServerTransport | SSEServerTransport, + ): void { + const originalOnMessage = transport.onmessage; + 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; + + // Extract metadata from params if present - it's probably not worth the effort + // to type it properly here - so we'll just pry the metadata out if exists. + const metadata = + params && typeof params === "object" && "_meta" in params + ? ((params as Record)._meta as Record< + string, + string + >) + : undefined; + + try { + // Let the server handle the message + if (originalOnMessage) { + await originalOnMessage.call(transport, message); + } + + // Record successful request/notification + this.recordedRequests.push({ + method, + params, + headers: { ...this.currentRequestHeaders }, + metadata: metadata ? { ...metadata } : undefined, + response: { processed: true }, + timestamp, + }); + } catch (error) { + // Record error + this.recordedRequests.push({ + method, + params, + headers: { ...this.currentRequestHeaders }, + metadata: metadata ? { ...metadata } : undefined, + response: { + error: error instanceof Error ? error.message : String(error), + }, + timestamp, + }); + throw error; + } + }; + } + + /** + * Start the server using the configuration from ServerConfig + */ + async start(): Promise { + setTestServerControl(this.serverControl); + const serverType = this.config.serverType ?? "streamable-http"; + const requestedPort = this.config.port; + + // If a port is explicitly requested, find an available port starting from that value + // Otherwise, use 0 to let the OS assign an available port + const port = requestedPort ? await findAvailablePort(requestedPort) : 0; + + if (serverType === "streamable-http") { + return this.startHttp(port); + } else { + return this.startSse(port); + } + } + + private async startHttp(port: number): Promise { + const app = express(); + app.use(express.json()); + + // Create HTTP server + this.httpServer = createHttpServer(app); + + // Set up OAuth if enabled (BEFORE MCP routes) + if (this.config.oauth?.enabled) { + // We need baseUrl, but it's not set yet - we'll set it after server starts + setupOAuthRoutes(app, this.config.oauth); + } + + // Store transports and one McpServer per session (SDK allows only one transport per server) + const transports: Map = new Map(); + this.mcpServersBySession = new Map(); + + // Bearer token middleware for MCP routes if requireAuth + const mcpMiddleware: express.RequestHandler[] = []; + if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { + mcpMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + } + + // Set up Express route to handle MCP requests + app.post("/mcp", ...mcpMiddleware, async (req: Request, res: Response) => { + // If middleware already sent a response (401), don't continue + if (res.headersSent) { + return; + } + // Capture headers for this request + this.currentRequestHeaders = extractHeaders(req); + + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + if (sessionId) { + // Existing session - use the transport for this session + const transport = transports.get(sessionId); + if (!transport) { + res.status(404).json({ error: "Session not found" }); + return; + } + + try { + await transport.handleRequest(req, res, req.body); + } catch (error) { + // If response already sent (e.g., by OAuth middleware), don't send another + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + } + } else { + // New session - create a new transport and a new McpServer (one server per connection) + const sessionMcpServer = createMcpServer(this.configWithCallback); + const newTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (sessionId: string) => { + transports.set(sessionId, newTransport); + this.mcpServersBySession!.set(sessionId, sessionMcpServer); + }, + onsessionclosed: async (sessionId: string) => { + const mcp = this.mcpServersBySession?.get(sessionId); + transports.delete(sessionId); + this.mcpServersBySession?.delete(sessionId); + if (mcp) await mcp.close(); + }, + }); + + // Set up message interception for this transport + this.setupMessageInterception(newTransport); + + // Connect this session's MCP server to this transport + await sessionMcpServer.connect(newTransport); + + try { + await newTransport.handleRequest(req, res, req.body); + } catch (error) { + // If response already sent (e.g., by OAuth middleware), don't send another + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + }); + + // Handle GET requests for SSE stream - this enables server-initiated messages + app.get("/mcp", ...mcpMiddleware, async (req: Request, res: Response) => { + // Get session ID from header - required for streamable-http + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId) { + res.status(400).json({ + error: "Bad Request: Mcp-Session-Id header is required", + }); + return; + } + + // Look up the transport for this session + const transport = transports.get(sessionId); + if (!transport) { + res.status(404).json({ + error: "Session not found", + }); + return; + } + + // Let the transport handle the GET request + this.currentRequestHeaders = extractHeaders(req); + try { + await transport.handleRequest(req, res); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + } + }); + + // Start listening on localhost only to avoid macOS firewall prompts + // Use port 0 to let the OS assign an available port if no port was specified + return new Promise((resolve, reject) => { + this.httpServer!.listen(port, "127.0.0.1", () => { + const address = this.httpServer!.address(); + const assignedPort = + typeof address === "object" && address !== null ? address.port : port; + this.baseUrl = `http://localhost:${assignedPort}`; + resolve(assignedPort); + }); + this.httpServer!.on("error", reject); + }); + } + + private async startSse(port: number): Promise { + const app = express(); + app.use(express.json()); + + // Create HTTP server + this.httpServer = createHttpServer(app); + + // Set up OAuth if enabled (BEFORE MCP routes) + // Note: We use port 0 to let OS assign port, so we can't know the actual port yet + // But the routes use relative paths, so they should work regardless + if (this.config.oauth?.enabled) { + // Use placeholder URL - actual baseUrl will be set after server starts + setupOAuthRoutes(app, this.config.oauth); + } + + // Bearer token middleware for SSE routes if requireAuth + const sseMiddleware: express.RequestHandler[] = []; + if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { + sseMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + } + + // One McpServer per connection (same pattern as streamable-http) + this.mcpServersBySession = new Map(); + const sseTransports: Map = new Map(); + + // GET handler for SSE connection (establishes the SSE stream) + app.get("/sse", ...sseMiddleware, async (req: Request, res: Response) => { + this.currentRequestHeaders = extractHeaders(req); + const sessionMcpServer = createMcpServer(this.configWithCallback); + const sseTransport = new SSEServerTransport("/sse", res); + + const sessionId = sseTransport.sessionId; + sseTransports.set(sessionId, sseTransport); + this.mcpServersBySession!.set(sessionId, sessionMcpServer); + + // Clean up on connection close + res.on("close", async () => { + const mcp = this.mcpServersBySession?.get(sessionId); + sseTransports.delete(sessionId); + this.mcpServersBySession?.delete(sessionId); + if (mcp) await mcp.close(); + }); + + // Intercept messages + this.setupMessageInterception(sseTransport); + + // Connect this connection's MCP server to this transport + await sessionMcpServer.connect(sseTransport); + }); + + // POST handler for SSE message sending (SSE uses GET for stream, POST for sending messages) + app.post("/sse", ...sseMiddleware, async (req: Request, res: Response) => { + this.currentRequestHeaders = extractHeaders(req); + const sessionId = req.query.sessionId as string | undefined; + + if (!sessionId) { + res.status(400).json({ error: "Missing sessionId query parameter" }); + return; + } + + const transport = sseTransports.get(sessionId); + if (!transport) { + res.status(404).json({ error: "No transport found for sessionId" }); + return; + } + + try { + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + res.status(500).json({ + error: errorMessage, + }); + } + }); + + // Start listening on localhost only to avoid macOS firewall prompts + // Use port 0 to let the OS assign an available port if no port was specified + return new Promise((resolve, reject) => { + this.httpServer!.listen(port, "127.0.0.1", () => { + const address = this.httpServer!.address(); + const assignedPort = + typeof address === "object" && address !== null ? address.port : port; + this.baseUrl = `http://localhost:${assignedPort}`; + resolve(assignedPort); + }); + this.httpServer!.on("error", reject); + }); + } + + /** + * Stop the server. Set closing before closing transport so in-flight tools can skip sending. + */ + async stop(): Promise { + this._closing = true; + // Close all per-connection McpServers (SSE and streamable-http both use the map) + if (this.mcpServersBySession) { + for (const mcp of this.mcpServersBySession.values()) { + await mcp.close(); + } + this.mcpServersBySession.clear(); + this.mcpServersBySession = undefined; + } + + if (this.transport) { + await this.transport.close(); + this.transport = undefined; + } + + if (this.httpServer) { + return new Promise((resolve) => { + // Force close all connections + this.httpServer!.closeAllConnections?.(); + this.httpServer!.close(() => { + this.httpServer = undefined; + setTestServerControl(null); + resolve(); + }); + }); + } else { + setTestServerControl(null); + } + } + + /** + * Get all recorded requests + */ + getRecordedRequests(): RecordedRequest[] { + return [...this.recordedRequests]; + } + + /** + * Clear recorded requests + */ + clearRecordings(): void { + this.recordedRequests = []; + } + + /** + * Wait until a recorded request matches the predicate, or reject after timeout. + * Use instead of polling getRecordedRequests() with manual delays. + */ + waitUntilRecorded( + predicate: (req: RecordedRequest) => boolean, + options?: { timeout?: number; interval?: number }, + ): Promise { + const { timeout = 5000, interval = 10 } = options ?? {}; + const start = Date.now(); + return new Promise((resolve, reject) => { + const check = () => { + const req = this.getRecordedRequests().find(predicate); + if (req) { + resolve(req); + return; + } + if (Date.now() - start >= timeout) { + reject( + new Error( + `Timeout (${timeout}ms) waiting for recorded request matching predicate`, + ), + ); + return; + } + setTimeout(check, interval); + }; + check(); + }); + } + + /** + * Get the server URL with the appropriate endpoint path + */ + get url(): string { + if (!this.baseUrl) { + throw new Error("Server not started"); + } + const serverType = this.config.serverType ?? "streamable-http"; + const endpoint = serverType === "sse" ? "/sse" : "/mcp"; + return `${this.baseUrl}${endpoint}`; + } + + /** + * Get the most recent log level that was set + */ + getCurrentLogLevel(): string | null { + return this.currentLogLevel; + } +} + +/** + * Create an HTTP/SSE MCP test server + */ +export function createTestServerHttp(config: ServerConfig): TestServerHttp { + return new TestServerHttp(config); +} diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts new file mode 100644 index 000000000..0a86f84a3 --- /dev/null +++ b/test-servers/src/test-server-oauth.ts @@ -0,0 +1,661 @@ +/** + * OAuth Test Server Infrastructure + * + * Provides OAuth 2.1 authorization server functionality for test servers. + * Integrates with Express apps to add OAuth endpoints and Bearer token verification. + */ + +import crypto from "node:crypto"; +import type { Request, Response } from "express"; +import express from "express"; +import type { ServerConfig } from "./composable-test-server.js"; + +/** + * OAuth configuration from ServerConfig + */ +export type OAuthConfig = NonNullable; + +/** + * Set up OAuth routes on an Express application + * This adds all OAuth endpoints (authorization, token, metadata, etc.) + * + * @param app - Express application + * @param config - OAuth configuration + */ +export function setupOAuthRoutes( + app: express.Application, + config: OAuthConfig, +): void { + // OAuth metadata endpoints (RFC 8414) + setupMetadataEndpoints(app, config); + + // OAuth authorization endpoint + setupAuthorizationEndpoint(app, config); + + // OAuth token endpoint + setupTokenEndpoint(app, config); + + // Dynamic Client Registration endpoint (if enabled) + if (config.supportDCR) { + setupDCREndpoint(app); + } +} + +/** + * Create Bearer token verification middleware + * Returns 401 if token is missing or invalid when requireAuth is true + * + * @param config - OAuth configuration + * @returns Express middleware function + */ +export function createBearerTokenMiddleware( + config: OAuthConfig, +): express.RequestHandler { + return async (req: Request, res: Response, next: express.NextFunction) => { + if (!config.requireAuth) { + return next(); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + // Return 401 - the SDK's transport should detect this and throw an error + // For streamable-http, the SDK checks response status and throws StreamableHTTPError with code 401 + res.status(401); + res.setHeader("Content-Type", "application/json"); + res.setHeader("WWW-Authenticate", "Bearer"); + // Return a JSON-RPC error response format that the SDK will recognize + res.json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Unauthorized: Missing or invalid Bearer token (401)", + }, + id: null, + }); + return; + } + + const token = authHeader.substring(7); // Remove "Bearer " prefix + + // Verify token (simplified for test server - in production, use proper JWT verification) + if (!isValidToken(token)) { + // Return 401 - the SDK's transport should detect this and throw an error + res.status(401); + res.setHeader("Content-Type", "application/json"); + res.setHeader("WWW-Authenticate", "Bearer"); + // Return a JSON-RPC error response format that the SDK will recognize + res.json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Unauthorized: Invalid or expired token (401)", + }, + id: null, + }); + return; + } + + // Attach token info to request for use in handlers + (req as Request & { oauthToken?: string }).oauthToken = token; + next(); + }; +} + +/** + * Set up OAuth metadata endpoints (RFC 8414) + */ +function setupMetadataEndpoints( + app: express.Application, + config: OAuthConfig, +): void { + const scopes = config.scopesSupported || ["mcp"]; + + // OAuth Authorization Server Metadata + app.get( + "/.well-known/oauth-authorization-server", + (req: Request, res: Response) => { + // Use request's host to get actual server URL (since port is assigned dynamically) + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + const actualIssuerUrl = new URL(requestBaseUrl); + const metadata = { + issuer: actualIssuerUrl.href, + authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl) + .href, + token_endpoint: new URL("/oauth/token", actualIssuerUrl).href, + scopes_supported: scopes, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], // PKCE support + token_endpoint_auth_methods_supported: ["client_secret_basic", "none"], + ...(config.supportDCR && { + registration_endpoint: new URL("/oauth/register", actualIssuerUrl) + .href, + }), + ...(config.supportCIMD && { + client_id_metadata_document_supported: true, + }), + }; + + res.json(metadata); + }, + ); + + // OAuth Protected Resource Metadata + app.get( + "/.well-known/oauth-protected-resource", + (req: Request, res: Response) => { + // Use request's host so resource matches actual server URL (port 0 → assigned port) + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + const actualResourceUrl = new URL("/", requestBaseUrl).href; + const metadata = { + resource: actualResourceUrl, + authorization_servers: [actualResourceUrl], + scopes_supported: scopes, + }; + + res.json(metadata); + }, + ); +} + +/** + * Set up OAuth authorization endpoint + * For test servers, this auto-approves requests and redirects with authorization code + */ +function setupAuthorizationEndpoint( + app: express.Application, + config: OAuthConfig, +): void { + app.get("/oauth/authorize", async (req: Request, res: Response) => { + const { + client_id, + redirect_uri, + response_type, + scope, + state, + code_challenge, + code_challenge_method, + } = req.query; + + // Validate required parameters + if (!client_id || !redirect_uri || !response_type) { + res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters", + }); + return; + } + + if (response_type !== "code") { + res.status(400).json({ error: "unsupported_response_type" }); + return; + } + + // Validate client (check static clients, DCR, or CIMD) + const client = await findClient(client_id as string, config); + if (!client) { + res.status(400).json({ error: "invalid_client" }); + return; + } + + // Validate redirect_uri + if ( + client.redirectUris && + !client.redirectUris.includes(redirect_uri as string) + ) { + res.status(400).json({ + error: "invalid_request", + error_description: "Invalid redirect_uri", + }); + return; + } + + // Validate PKCE + if (code_challenge_method && code_challenge_method !== "S256") { + res.status(400).json({ + error: "invalid_request", + error_description: "Unsupported code_challenge_method", + }); + return; + } + + // For test servers, auto-approve and generate authorization code + const authCode = generateAuthorizationCode(); + + // Store authorization code temporarily (in production, use proper storage) + storeAuthorizationCode(authCode, { + clientId: client_id as string, + redirectUri: redirect_uri as string, + codeChallenge: code_challenge as string | undefined, + scope: scope as string | undefined, + }); + + // Redirect with authorization code + const redirectUrl = new URL(redirect_uri as string); + redirectUrl.searchParams.set("code", authCode); + if (state) { + redirectUrl.searchParams.set("state", state as string); + } + + res.redirect(redirectUrl.href); + }); +} + +/** + * Set up OAuth token endpoint + */ +function setupTokenEndpoint( + app: express.Application, + config: OAuthConfig, +): void { + app.post( + "/oauth/token", + express.urlencoded({ extended: true }), + async (req: Request, res: Response) => { + const { + grant_type, + code, + redirect_uri, + client_id: bodyClientId, + code_verifier, + refresh_token, + } = req.body; + + // Extract client_id from either body (client_secret_post) or Authorization header (client_secret_basic) + let client_id = bodyClientId; + let client_secret: string | undefined; + + // Check Authorization header for client_secret_basic + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith("Basic ")) { + const credentials = Buffer.from(authHeader.slice(6), "base64").toString( + "utf-8", + ); + const [id, secret] = credentials.split(":", 2); + client_id = id; + client_secret = secret; + } + + if (grant_type === "authorization_code") { + // Authorization code flow + if (!code || !redirect_uri || !client_id) { + res.status(400).json({ + error: "invalid_request", + error_description: "Missing required parameters", + }); + return; + } + + const authCodeData = getAuthorizationCode(code); + if (!authCodeData) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid or expired authorization code", + }); + return; + } + + // Verify client + const client = await findClient(client_id, config); + if (!client || client.clientId !== authCodeData.clientId) { + res.status(400).json({ error: "invalid_client" }); + return; + } + + // Verify client secret if provided (for client_secret_basic) + if ( + client_secret && + client.clientSecret && + client.clientSecret !== client_secret + ) { + res.status(400).json({ error: "invalid_client" }); + return; + } + + // Verify redirect_uri + if (authCodeData.redirectUri !== redirect_uri) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Redirect URI mismatch", + }); + return; + } + + // Verify PKCE code verifier + if (authCodeData.codeChallenge) { + if (!code_verifier) { + res.status(400).json({ + error: "invalid_request", + error_description: "code_verifier required", + }); + return; + } + // Proper PKCE verification: code_challenge should be base64url(SHA256(code_verifier)) + const hash = crypto + .createHash("sha256") + .update(code_verifier) + .digest(); + // Convert to base64url (replace + with -, / with _, remove padding) + const expectedChallenge = hash + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + if (authCodeData.codeChallenge !== expectedChallenge) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid code_verifier", + }); + return; + } + } + + // Generate access token + const accessToken = generateAccessToken(); + const tokenExpiration = config.tokenExpirationSeconds || 3600; + + const response: { + access_token: string; + token_type: string; + expires_in: number; + scope: string; + refresh_token?: string; + } = { + access_token: accessToken, + token_type: "Bearer", + expires_in: tokenExpiration, + scope: authCodeData.scope || config.scopesSupported?.[0] || "mcp", + }; + + // Add refresh token if supported + if (config.supportRefreshTokens !== false) { + const refreshToken = generateRefreshToken(); + response.refresh_token = refreshToken; + storeRefreshToken(refreshToken, { + clientId: client_id, + scope: authCodeData.scope, + }); + } + + res.json(response); + } else if (grant_type === "refresh_token") { + // Refresh token flow + if (!refresh_token || !client_id) { + res.status(400).json({ error: "invalid_request" }); + return; + } + + const refreshTokenData = getRefreshToken(refresh_token); + if (!refreshTokenData || refreshTokenData.clientId !== client_id) { + res.status(400).json({ error: "invalid_grant" }); + return; + } + + const accessToken = generateAccessToken(); + const tokenExpiration = config.tokenExpirationSeconds || 3600; + + res.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: tokenExpiration, + scope: refreshTokenData.scope || config.scopesSupported?.[0] || "mcp", + }); + } else { + res.status(400).json({ error: "unsupported_grant_type" }); + } + }, + ); +} + +/** + * Set up Dynamic Client Registration endpoint + */ +function setupDCREndpoint(app: express.Application): void { + app.post("/oauth/register", express.json(), (req: Request, res: Response) => { + const { redirect_uris, client_name, scope } = req.body; + + if ( + !redirect_uris || + !Array.isArray(redirect_uris) || + redirect_uris.length === 0 + ) { + res.status(400).json({ error: "invalid_client_metadata" }); + return; + } + + dcrRequests.push({ redirect_uris: [...redirect_uris] }); + + // Generate client ID and secret + const clientId = generateClientId(); + const clientSecret = generateClientSecret(); + + // Store registered client + registerClient(clientId, { + clientSecret, + redirectUris: redirect_uris, + clientName: client_name, + scope, + }); + + res.status(201).json({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris, + ...(client_name && { client_name }), + ...(scope && { scope }), + }); + }); +} + +// In-memory storage for test server (simplified - not production-ready) +interface AuthorizationCodeData { + clientId: string; + redirectUri: string; + codeChallenge?: string; + scope?: string; + expiresAt: number; +} + +interface RefreshTokenData { + clientId: string; + scope?: string; +} + +interface RegisteredClient { + clientSecret?: string; + redirectUris: string[]; + clientName?: string; + scope?: string; +} + +const authorizationCodes = new Map(); +const accessTokens = new Set(); +const refreshTokens = new Map(); +const registeredClients = new Map(); + +/** Recorded DCR request bodies (redirect_uris) for tests that verify both URLs are registered. */ +const dcrRequests: Array<{ redirect_uris: string[] }> = []; + +/** + * Check if a string is a valid URL + */ +function isUrl(str: string): boolean { + try { + new URL(str); + return true; + } catch { + return false; + } +} + +/** + * Fetch client metadata document from URL (for CIMD) + */ +async function fetchClientMetadata(metadataUrl: string): Promise<{ + redirect_uris: string[]; + token_endpoint_auth_method?: string; + grant_types?: string[]; + response_types?: string[]; + client_name?: string; + client_uri?: string; + scope?: string; +} | null> { + try { + const response = await fetch(metadataUrl); + if (!response.ok) { + return null; + } + const metadata = await response.json(); + return metadata; + } catch { + return null; + } +} + +async function findClient( + clientId: string, + config: OAuthConfig, +): Promise<{ + clientId: string; + clientSecret?: string; + redirectUris?: string[]; +} | null> { + // Check static clients first + if (config.staticClients) { + const staticClient = config.staticClients.find( + (c) => c.clientId === clientId, + ); + if (staticClient) { + return { + clientId: staticClient.clientId, + clientSecret: staticClient.clientSecret, + redirectUris: staticClient.redirectUris, + }; + } + } + + // Check registered clients (DCR) + if (registeredClients.has(clientId)) { + const client = registeredClients.get(clientId)!; + return { + clientId, + clientSecret: client.clientSecret, + redirectUris: client.redirectUris, + }; + } + + // Check CIMD: if client_id is a URL and CIMD is supported, fetch metadata + if (config.supportCIMD && isUrl(clientId)) { + const metadata = await fetchClientMetadata(clientId); + if ( + metadata && + metadata.redirect_uris && + Array.isArray(metadata.redirect_uris) + ) { + // For CIMD, the client_id is the URL itself, and there's no client_secret + // (CIMD uses token_endpoint_auth_method: "none" typically) + return { + clientId, // The URL is the client_id + clientSecret: undefined, // CIMD typically doesn't use secrets + redirectUris: metadata.redirect_uris, + }; + } + } + + return null; +} + +function generateAuthorizationCode(): string { + return `test_auth_code_${Date.now()}_${Math.random().toString(36).substring(7)}`; +} + +function storeAuthorizationCode( + code: string, + data: Omit, +): void { + authorizationCodes.set(code, { + ...data, + expiresAt: Date.now() + 60000, // 1 minute expiration + }); +} + +function getAuthorizationCode(code: string): AuthorizationCodeData | null { + const data = authorizationCodes.get(code); + if (!data) { + return null; + } + + // Check expiration + if (Date.now() > data.expiresAt) { + authorizationCodes.delete(code); + return null; + } + + // Delete after use (authorization codes are single-use) + authorizationCodes.delete(code); + return data; +} + +function generateAccessToken(): string { + const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`; + accessTokens.add(token); + return token; +} + +function generateRefreshToken(): string { + return `test_refresh_token_${Date.now()}_${Math.random().toString(36).substring(7)}`; +} + +function storeRefreshToken(token: string, data: RefreshTokenData): void { + refreshTokens.set(token, data); +} + +function getRefreshToken(token: string): RefreshTokenData | null { + return refreshTokens.get(token) || null; +} + +function generateClientId(): string { + return `test_client_${Date.now()}_${Math.random().toString(36).substring(7)}`; +} + +function generateClientSecret(): string { + return `test_secret_${Math.random().toString(36).substring(2, 15)}`; +} + +function registerClient(clientId: string, client: RegisteredClient): void { + registeredClients.set(clientId, client); +} + +function isValidToken(token: string): boolean { + // Simplified token validation for test server + // In production, verify JWT signature, expiration, etc. + return accessTokens.has(token); +} + +/** + * Clear all OAuth test data (useful for test cleanup) + */ +export function clearOAuthTestData(): void { + authorizationCodes.clear(); + accessTokens.clear(); + refreshTokens.clear(); + registeredClients.clear(); + dcrRequests.length = 0; +} + +/** + * Returns recorded DCR request bodies (redirect_uris) for tests that verify + * both normal and guided redirect URLs are registered. + */ +export function getDCRRequests(): Array<{ redirect_uris: string[] }> { + return dcrRequests; +} + +/** + * Invalidate a single access token (remove from valid set). + * Used by E2E tests to simulate expired/revoked access token while keeping + * refresh_token valid, so 401 → auth() → refresh → retry can be exercised. + */ +export function invalidateAccessToken(token: string): void { + accessTokens.delete(token); +} diff --git a/test-servers/src/test-server-stdio.ts b/test-servers/src/test-server-stdio.ts new file mode 100644 index 000000000..d7b7a4457 --- /dev/null +++ b/test-servers/src/test-server-stdio.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +/** + * Test MCP server for stdio transport testing + * Can be used programmatically or run as a standalone executable + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { fileURLToPath } from "url"; +import type { + ServerConfig, + ResourceDefinition, +} from "./test-server-fixtures.js"; +import { + getDefaultServerConfig, + createMcpServer, +} from "./test-server-fixtures.js"; + +export class TestServerStdio { + private mcpServer: McpServer; + private config: ServerConfig; + private transport?: StdioServerTransport; + + constructor(config: ServerConfig) { + // Provide callback to customize resource handlers for stdio-specific dynamic resources + const configWithCallback: ServerConfig = { + ...config, + onRegisterResource: (resource: ResourceDefinition) => { + // Only provide custom handler for dynamic resources + if ( + resource.name === "test_cwd" || + resource.name === "test_env" || + resource.name === "test_argv" + ) { + return async () => { + let text: string; + if (resource.name === "test_cwd") { + text = process.cwd(); + } else if (resource.name === "test_env") { + text = JSON.stringify(process.env, null, 2); + } else if (resource.name === "test_argv") { + text = JSON.stringify(process.argv, null, 2); + } else { + text = resource.text ?? ""; + } + + return { + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType || "text/plain", + text, + }, + ], + }; + }; + } + // Return undefined to use default handler + return undefined; + }, + }; + this.config = config; + this.mcpServer = createMcpServer(configWithCallback); + } + + /** + * Start the server with stdio transport + */ + async start(): Promise { + this.transport = new StdioServerTransport(); + await this.mcpServer.connect(this.transport); + } + + /** + * Stop the server + */ + async stop(): Promise { + await this.mcpServer.close(); + if (this.transport) { + await this.transport.close(); + this.transport = undefined; + } + } +} + +/** + * Create a stdio MCP test server + */ +export function createTestServerStdio(config: ServerConfig): TestServerStdio { + return new TestServerStdio(config); +} + +/** + * Get the path to the test MCP server script. + * Uses the actual loaded module path so it works when loaded from source (.ts) or build (.js). + */ +export function getTestMcpServerPath(): string { + return fileURLToPath(import.meta.url); +} + +/** + * Get the command and args to run the test MCP server + * Uses node to run the built output (test package must be built first) + */ +export function getTestMcpServerCommand(): { command: string; args: string[] } { + return { + command: "node", + args: [getTestMcpServerPath()], + }; +} + +// If run as a standalone script, start with default config +// Check if this file is being executed directly (not imported) +const isMainModule = + import.meta.url.endsWith(process.argv[1] || "") || + (process.argv[1]?.endsWith("test-server-stdio.ts") ?? false) || + (process.argv[1]?.endsWith("test-server-stdio.js") ?? false); + +if (isMainModule) { + const server = new TestServerStdio(getDefaultServerConfig()); + server + .start() + .then(() => { + // Server is now running and listening on stdio + // Keep the process alive + }) + .catch((error) => { + console.error("Failed to start test MCP server:", error); + process.exit(1); + }); +} diff --git a/test-servers/tsconfig.json b/test-servers/tsconfig.json new file mode 100644 index 000000000..a0f2d994c --- /dev/null +++ b/test-servers/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "build"] +} diff --git a/test-servers/vitest.config.ts b/test-servers/vitest.config.ts new file mode 100644 index 000000000..dc59c0087 --- /dev/null +++ b/test-servers/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["__tests__/**/*.test.ts"], + testTimeout: 15000, + }, +}); diff --git a/tui/__tests__/tui.test.ts b/tui/__tests__/tui.test.ts new file mode 100644 index 000000000..7e0072e08 --- /dev/null +++ b/tui/__tests__/tui.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { tabs } from "../src/components/tabsConfig.js"; + +describe("TUI", () => { + it("exports tabs with expected shape", () => { + expect(Array.isArray(tabs)).toBe(true); + expect(tabs.length).toBeGreaterThan(0); + for (const tab of tabs) { + expect(tab).toHaveProperty("id"); + expect(tab).toHaveProperty("label"); + expect(tab).toHaveProperty("accelerator"); + } + }); + + it("includes info tab", () => { + const info = tabs.find((t) => t.id === "info"); + expect(info).toBeDefined(); + expect(info?.label).toBe("Info"); + expect(info?.accelerator).toBe("i"); + }); +}); diff --git a/tui/package.json b/tui/package.json new file mode 100644 index 000000000..b360e2cdd --- /dev/null +++ b/tui/package.json @@ -0,0 +1,44 @@ +{ + "name": "@modelcontextprotocol/inspector-tui", + "version": "0.20.0", + "description": "Terminal User Interface (TUI) for the Model Context Protocol inspector", + "license": "MIT", + "author": { + "name": "Bob Dickinson (TeamSpark AI)", + "email": "bob@teamspark.ai" + }, + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/inspector/issues", + "type": "module", + "main": "build/tui.js", + "bin": { + "mcp-inspector-tui": "./build/tui.js" + }, + "files": [ + "build" + ], + "scripts": { + "build": "tsc", + "dev": "NODE_PATH=../node_modules:./node_modules:$NODE_PATH tsx tui.tsx", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/inspector-core": "*", + "pino": "^9.6.0", + "commander": "^13.1.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "ink": "^5.2.1", + "ink-form": "^2.0.1", + "ink-scroll-view": "^0.3.6", + "open": "^10.2.0", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "@types/react": "^18.3.23", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.17" + } +} diff --git a/tui/src/App.tsx b/tui/src/App.tsx new file mode 100644 index 000000000..c02cda563 --- /dev/null +++ b/tui/src/App.tsx @@ -0,0 +1,1848 @@ +import React, { + useState, + useMemo, + useEffect, + useCallback, + useRef, +} from "react"; +import { Box, Text, useInput, useApp, type Key } from "ink"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import type { + MessageEntry, + FetchRequestEntry, + MCPServerConfig, + InspectorClientOptions, + InspectorClientEnvironment, +} from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { + Tool, + Resource, + Prompt, + PromptArgument, + GetPromptResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { + ManagedToolsState, + ManagedResourcesState, + ManagedResourceTemplatesState, + ManagedPromptsState, + MessageLogState, + FetchRequestLogState, + StderrLogState, +} from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; +import { + loadMcpServersConfig, + createTransportNode, +} from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; +import { useManagedTools } from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; +import { useManagedResources } from "@modelcontextprotocol/inspector-core/react/useManagedResources.js"; +import { useManagedResourceTemplates } from "@modelcontextprotocol/inspector-core/react/useManagedResourceTemplates.js"; +import { useManagedPrompts } from "@modelcontextprotocol/inspector-core/react/useManagedPrompts.js"; +import { useMessageLog } from "@modelcontextprotocol/inspector-core/react/useMessageLog.js"; +import { useFetchRequestLog } from "@modelcontextprotocol/inspector-core/react/useFetchRequestLog.js"; +import { useStderrLog } from "@modelcontextprotocol/inspector-core/react/useStderrLog.js"; +import { + CallbackNavigation, + MutableRedirectUrlProvider, +} from "@modelcontextprotocol/inspector-core/auth"; +import { + createOAuthCallbackServer, + type OAuthCallbackServer, + NodeOAuthStorage, +} from "@modelcontextprotocol/inspector-core/auth/node/index.js"; +import { tuiLogger } from "./logger.js"; +import { openUrl } from "./utils/openUrl.js"; +import { Tabs } from "./components/Tabs.js"; +import { type TabType, tabs as tabList } from "./components/tabsConfig.js"; +import { InfoTab } from "./components/InfoTab.js"; +import { AuthTab } from "./components/AuthTab.js"; +import { ResourcesTab } from "./components/ResourcesTab.js"; +import { PromptsTab } from "./components/PromptsTab.js"; +import { ToolsTab } from "./components/ToolsTab.js"; +import { NotificationsTab } from "./components/NotificationsTab.js"; +import { HistoryTab } from "./components/HistoryTab.js"; +import { RequestsTab } from "./components/RequestsTab.js"; +import { ToolTestModal } from "./components/ToolTestModal.js"; +import { ResourceTestModal } from "./components/ResourceTestModal.js"; +import { PromptTestModal } from "./components/PromptTestModal.js"; +import { DetailsModal } from "./components/DetailsModal.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read package.json to get project info +// Strategy: Try multiple paths to handle both local dev and global install +// - Local dev (tsx): __dirname = src/, package.json is one level up +// - Global install: __dirname = dist/src/, package.json is two levels up +let packagePath: string; +let packageJson: { name: string; description: string; version: string }; + +try { + // Try two levels up first (global install case) + packagePath = join(__dirname, "..", "..", "package.json"); + packageJson = JSON.parse(readFileSync(packagePath, "utf-8")) as { + name: string; + description: string; + version: string; + }; +} catch { + // Fall back to one level up (local dev case) + packagePath = join(__dirname, "..", "package.json"); + packageJson = JSON.parse(readFileSync(packagePath, "utf-8")) as { + name: string; + description: string; + version: string; + }; +} + +// Focus management types +type FocusArea = + | "serverList" + | "tabs" + // Used by Resources/Prompts/Tools - list pane + | "tabContentList" + // Used by Resources/Prompts/Tools - details pane + | "tabContentDetails" + // Used only when activeTab === 'messages' + | "messagesList" + | "messagesDetail" + // Used only when activeTab === 'requests' + | "requestsList" + | "requestsDetail"; + +interface AppProps { + configFile: string; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrlConfig: { + hostname: string; + port: number; + pathname: string; + }; +} + +/** HTTP transports (SSE, streamable-http) can use OAuth. No config gate. */ +function isOAuthCapableServer(config: MCPServerConfig | null): boolean { + if (!config) return false; + const c = config as MCPServerConfig & { oauth?: unknown }; + return c.type === "sse" || c.type === "streamable-http"; +} + +function App({ + configFile, + clientId, + clientSecret, + clientMetadataUrl, + callbackUrlConfig, +}: AppProps) { + const { exit } = useApp(); + const callbackServerBaseOptions = useMemo( + () => ({ + port: callbackUrlConfig.port, + hostname: callbackUrlConfig.hostname, + path: callbackUrlConfig.pathname, + }), + [callbackUrlConfig], + ); + + useEffect(() => { + tuiLogger.info({ configFile }, "TUI started"); + }, [configFile]); + + const [selectedServer, setSelectedServer] = useState(null); + const [activeTab, setActiveTab] = useState("info"); + const [focus, setFocus] = useState("serverList"); + const [tabCounts, setTabCounts] = useState<{ + info?: number; + resources?: number; + prompts?: number; + tools?: number; + messages?: number; + requests?: number; + logging?: number; + }>({}); + const [oauthStatus, setOauthStatus] = useState< + "idle" | "authenticating" | "success" | "error" + >("idle"); + const [oauthMessage, setOauthMessage] = useState(null); + const oauthInProgressRef = useRef(false); + const [selectedAuthAction, setSelectedAuthAction] = useState< + "guided" | "quick" | "clear" + >("guided"); + + // Tool test modal state + const [toolTestModal, setToolTestModal] = useState<{ + tool: Tool; + inspectorClient: InspectorClient | null; + } | null>(null); + + // Resource test modal state + const [resourceTestModal, setResourceTestModal] = useState<{ + template: { + name: string; + uriTemplate: string; + description?: string; + }; + inspectorClient: InspectorClient | null; + } | null>(null); + + // Prompt test modal state + const [promptTestModal, setPromptTestModal] = useState<{ + prompt: Prompt; + inspectorClient: InspectorClient | null; + } | null>(null); + + // Details modal state + const [detailsModal, setDetailsModal] = useState<{ + title: string; + content: React.ReactNode; + } | null>(null); + + // InspectorClient instances for each server + const [inspectorClients, setInspectorClients] = useState< + Record + >({}); + // ManagedToolsState per server (tools list from manager, not client) + const [managedToolsStates, setManagedToolsStates] = useState< + Record + >({}); + const [managedResourcesStates, setManagedResourcesStates] = useState< + Record + >({}); + const [managedResourceTemplatesStates, setManagedResourceTemplatesStates] = + useState>({}); + const [managedPromptsStates, setManagedPromptsStates] = useState< + Record + >({}); + const [messageLogStates, setMessageLogStates] = useState< + Record + >({}); + const [fetchRequestLogStates, setFetchRequestLogStates] = useState< + Record + >({}); + const [stderrLogStates, setStderrLogStates] = useState< + Record + >({}); + const [dimensions, setDimensions] = useState({ + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }); + + useEffect(() => { + const updateDimensions = () => { + setDimensions({ + width: process.stdout.columns || 80, + height: process.stdout.rows || 24, + }); + }; + + process.stdout.on("resize", updateDimensions); + return () => { + process.stdout.off("resize", updateDimensions); + }; + }, []); + + // Parse MCP configuration + const mcpConfig = useMemo(() => { + try { + return loadMcpServersConfig(configFile); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error("Error loading configuration: Unknown error"); + } + process.exit(1); + } + }, [configFile]); + + const serverNames = Object.keys(mcpConfig.mcpServers); + const selectedServerConfig = selectedServer + ? mcpConfig.mcpServers[selectedServer] + : null; + + // Mutable redirect URL providers, keyed by server name (populated before authenticate) + const redirectUrlProvidersRef = useRef< + Record + >({}); + + // Create InspectorClient and state managers for each server on mount + useEffect(() => { + const newClients: Record = {}; + const newManagers: Record = {}; + const newManagedResourcesStates: Record = {}; + const newManagedResourceTemplatesStates: Record< + string, + ManagedResourceTemplatesState + > = {}; + const newManagedPromptsStates: Record = {}; + const newMessageLogStates: Record = {}; + const newFetchRequestLogStates: Record = {}; + const newStderrLogStates: Record = {}; + for (const serverName of serverNames) { + if (!(serverName in inspectorClients)) { + const serverConfig = mcpConfig.mcpServers[ + serverName + ] as MCPServerConfig & { + oauth?: Record; + }; + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + logger: tuiLogger, + }; + const opts: InspectorClientOptions = { + environment, + pipeStderr: true, + }; + if (isOAuthCapableServer(serverConfig)) { + const oauthFromConfig = serverConfig.oauth as + | { storagePath?: string } + | undefined; + const redirectUrlProvider = + redirectUrlProvidersRef.current[serverName] ?? + (redirectUrlProvidersRef.current[serverName] = + new MutableRedirectUrlProvider()); + environment.oauth = { + storage: new NodeOAuthStorage(oauthFromConfig?.storagePath), + navigation: new CallbackNavigation( + async (url) => await openUrl(url), + ), + redirectUrlProvider, + }; + opts.oauth = { + ...(serverConfig.oauth || {}), + ...(clientId && { clientId }), + ...(clientSecret && { clientSecret }), + ...(clientMetadataUrl && { clientMetadataUrl }), + }; + } + const client = new InspectorClient(serverConfig, opts); + newClients[serverName] = client; + newManagers[serverName] = new ManagedToolsState(client); + newManagedResourcesStates[serverName] = new ManagedResourcesState( + client, + ); + newManagedResourceTemplatesStates[serverName] = + new ManagedResourceTemplatesState(client); + newManagedPromptsStates[serverName] = new ManagedPromptsState(client); + newMessageLogStates[serverName] = new MessageLogState(client); + newFetchRequestLogStates[serverName] = new FetchRequestLogState(client); + newStderrLogStates[serverName] = new StderrLogState(client); + } + } + if (Object.keys(newClients).length > 0) { + setInspectorClients((prev) => ({ ...prev, ...newClients })); + setManagedToolsStates((prev) => ({ ...prev, ...newManagers })); + setManagedResourcesStates((prev) => ({ + ...prev, + ...newManagedResourcesStates, + })); + setManagedResourceTemplatesStates((prev) => ({ + ...prev, + ...newManagedResourceTemplatesStates, + })); + setManagedPromptsStates((prev) => ({ + ...prev, + ...newManagedPromptsStates, + })); + setMessageLogStates((prev) => ({ ...prev, ...newMessageLogStates })); + setFetchRequestLogStates((prev) => ({ + ...prev, + ...newFetchRequestLogStates, + })); + setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId, clientSecret, clientMetadataUrl]); + + // Cleanup: destroy managers and disconnect all clients on unmount + useEffect(() => { + return () => { + Object.values(managedToolsStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(managedResourcesStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(managedResourceTemplatesStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(managedPromptsStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(messageLogStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(fetchRequestLogStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(stderrLogStates).forEach((manager) => { + manager.destroy(); + }); + Object.values(inspectorClients).forEach((client) => { + client.disconnect().catch(() => { + // Ignore errors during cleanup + }); + }); + }; + }, [ + inspectorClients, + managedToolsStates, + managedResourcesStates, + managedResourceTemplatesStates, + managedPromptsStates, + messageLogStates, + fetchRequestLogStates, + stderrLogStates, + ]); + + // Preselect the first server on mount + useEffect(() => { + if (serverNames.length > 0 && selectedServer === null) { + setSelectedServer(serverNames[0]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clear OAuth status when switching servers + useEffect(() => { + setOauthStatus("idle"); + setOauthMessage(null); + }, [selectedServer]); + + // Switch away from Auth tab when server is not OAuth-capable + useEffect(() => { + if ( + activeTab === "auth" && + selectedServerConfig && + !isOAuthCapableServer(selectedServerConfig) + ) { + setActiveTab("info"); + } + }, [activeTab, selectedServerConfig]); + + // Get InspectorClient for selected server + const selectedInspectorClient = useMemo( + () => (selectedServer ? inspectorClients[selectedServer] : null), + [selectedServer, inspectorClients], + ); + + // Use the hook to get reactive state from InspectorClient + const { + status: inspectorStatus, + capabilities: inspectorCapabilities, + serverInfo: inspectorServerInfo, + instructions: inspectorInstructions, + connect: connectInspector, + disconnect: disconnectInspector, + } = useInspectorClient(selectedInspectorClient); + + // Log state from managers (per-server) + const selectedMessageLogState = useMemo( + () => + selectedServer && messageLogStates[selectedServer] + ? messageLogStates[selectedServer] + : null, + [selectedServer, messageLogStates], + ); + const selectedFetchRequestLogState = useMemo( + () => + selectedServer && fetchRequestLogStates[selectedServer] + ? fetchRequestLogStates[selectedServer] + : null, + [selectedServer, fetchRequestLogStates], + ); + const selectedStderrLogState = useMemo( + () => + selectedServer && stderrLogStates[selectedServer] + ? stderrLogStates[selectedServer] + : null, + [selectedServer, stderrLogStates], + ); + const { messages: inspectorMessages } = useMessageLog( + selectedMessageLogState, + ); + const { fetchRequests: inspectorFetchRequests } = useFetchRequestLog( + selectedFetchRequestLogState, + ); + const { stderrLogs: inspectorStderrLogs } = useStderrLog( + selectedStderrLogState, + ); + + // Tools from ManagedToolsState (full list, auto-load on connect) + const selectedManagedToolsState = useMemo( + () => + selectedServer && managedToolsStates[selectedServer] + ? managedToolsStates[selectedServer] + : null, + [selectedServer, managedToolsStates], + ); + const { tools: managedTools } = useManagedTools( + selectedInspectorClient, + selectedManagedToolsState, + ); + + // Resources, resource templates, prompts from managed state managers + const selectedManagedResourcesState = useMemo( + () => + selectedServer && managedResourcesStates[selectedServer] + ? managedResourcesStates[selectedServer] + : null, + [selectedServer, managedResourcesStates], + ); + const selectedManagedResourceTemplatesState = useMemo( + () => + selectedServer && managedResourceTemplatesStates[selectedServer] + ? managedResourceTemplatesStates[selectedServer] + : null, + [selectedServer, managedResourceTemplatesStates], + ); + const selectedManagedPromptsState = useMemo( + () => + selectedServer && managedPromptsStates[selectedServer] + ? managedPromptsStates[selectedServer] + : null, + [selectedServer, managedPromptsStates], + ); + const { resources: managedResources } = useManagedResources( + selectedInspectorClient, + selectedManagedResourcesState, + ); + const { resourceTemplates: managedResourceTemplates } = + useManagedResourceTemplates( + selectedInspectorClient, + selectedManagedResourceTemplatesState, + ); + const { prompts: managedPrompts } = useManagedPrompts( + selectedInspectorClient, + selectedManagedPromptsState, + ); + + // Connect handler - InspectorClient now handles fetching server data automatically + const handleConnect = useCallback(async () => { + if (!selectedServer || !selectedInspectorClient) return; + + try { + await connectInspector(); + // InspectorClient automatically fetches server data (capabilities, tools, resources, resource templates, prompts, etc.) + // on connect, so we don't need to do anything here + } catch { + // Error handling is done by InspectorClient and will be reflected in status + } + }, [selectedServer, selectedInspectorClient, connectInspector]); + + // Disconnect handler + const handleDisconnect = useCallback(async () => { + if (!selectedServer) return; + await disconnectInspector(); + // InspectorClient will update status automatically, and data is preserved + }, [selectedServer, disconnectInspector]); + + // Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect) + const callbackServerRef = useRef(null); + + // OAuth Quick Auth (normal mode; callback server + open URL) + const handleQuickAuth = useCallback(async () => { + if ( + !selectedServer || + !selectedInspectorClient || + !selectedServerConfig || + !isOAuthCapableServer(selectedServerConfig) + ) { + return; + } + if (oauthInProgressRef.current) return; + oauthInProgressRef.current = true; + setOauthStatus("authenticating"); + setOauthMessage(null); + tuiLogger.info( + { server: selectedServer }, + "OAuth authentication started (Quick Auth)", + ); + const existing = callbackServerRef.current; + if (existing) { + await existing.stop(); + callbackServerRef.current = null; + } + const callbackServer = createOAuthCallbackServer(); + callbackServerRef.current = callbackServer; + let flowResolve: () => void; + let flowReject: (err: Error) => void; + const flowDone = new Promise((resolve, reject) => { + flowResolve = resolve; + flowReject = reject; + }); + try { + const { redirectUrl } = await callbackServer.start({ + ...callbackServerBaseOptions, + onCallback: async (params) => { + try { + await selectedInspectorClient!.completeOAuthFlow(params.code); + flowResolve!(); + } catch (err) { + flowReject!(err instanceof Error ? err : new Error(String(err))); + } finally { + callbackServerRef.current = null; + } + }, + onError: (params) => { + flowReject!( + new Error( + params.error_description ?? params.error ?? "OAuth error", + ), + ); + void callbackServer.stop(); + callbackServerRef.current = null; + }, + }); + const redirectUrlProvider = + redirectUrlProvidersRef.current[selectedServer]; + if (redirectUrlProvider) { + redirectUrlProvider.redirectUrl = redirectUrl; + } + await selectedInspectorClient.authenticate(); + await flowDone; + setOauthStatus("success"); + setOauthMessage("OAuth complete. Press C to connect."); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setOauthStatus("error"); + setOauthMessage(msg); + } finally { + oauthInProgressRef.current = false; + } + }, [ + selectedServer, + selectedInspectorClient, + selectedServerConfig, + callbackServerBaseOptions, + ]); + + // OAuth Guided Auth - step-by-step + const handleGuidedStart = useCallback(async () => { + if ( + !selectedServer || + !selectedInspectorClient || + !selectedServerConfig || + !isOAuthCapableServer(selectedServerConfig) + ) { + return; + } + if (oauthInProgressRef.current) return; + oauthInProgressRef.current = true; + setOauthStatus("authenticating"); + setOauthMessage(null); + tuiLogger.info( + { server: selectedServer }, + "OAuth authentication started (Guided Auth)", + ); + // Stop any previous callback server (e.g. from failed auth where AS never redirected) + const existing = callbackServerRef.current; + if (existing) { + await existing.stop(); + callbackServerRef.current = null; + } + const callbackServer = createOAuthCallbackServer(); + callbackServerRef.current = callbackServer; + try { + const { redirectUrl } = await callbackServer.start({ + ...callbackServerBaseOptions, + onCallback: async (params) => { + try { + await selectedInspectorClient!.completeOAuthFlow(params.code); + setOauthStatus("success"); + setOauthMessage("OAuth complete. Press C to connect."); + } catch (err) { + setOauthStatus("error"); + setOauthMessage(err instanceof Error ? err.message : String(err)); + } finally { + callbackServerRef.current = null; + } + }, + onError: (params) => { + setOauthStatus("error"); + setOauthMessage( + params.error_description ?? params.error ?? "OAuth error", + ); + void callbackServer.stop(); + callbackServerRef.current = null; + }, + }); + const redirectUrlProvider = + redirectUrlProvidersRef.current[selectedServer]; + if (redirectUrlProvider) { + redirectUrlProvider.redirectUrl = redirectUrl; + } + await selectedInspectorClient.beginGuidedAuth(); + setOauthStatus("idle"); + } catch (err) { + setOauthStatus("error"); + setOauthMessage(err instanceof Error ? err.message : String(err)); + } finally { + oauthInProgressRef.current = false; + } + }, [ + selectedServer, + selectedInspectorClient, + selectedServerConfig, + callbackServerBaseOptions, + ]); + + const handleGuidedAdvance = useCallback(async () => { + if (!selectedInspectorClient) return; + if (oauthInProgressRef.current) return; + oauthInProgressRef.current = true; + setOauthStatus("authenticating"); + setOauthMessage(null); + tuiLogger.info("OAuth authentication started (Guided Auth advance step)"); + try { + await selectedInspectorClient.proceedOAuthStep(); + const state = selectedInspectorClient.getOAuthState(); + if (state?.oauthStep === "authorization_code" && state.authorizationUrl) { + await openUrl(state.authorizationUrl); + } + setOauthStatus("idle"); + } catch (err) { + setOauthStatus("error"); + setOauthMessage(err instanceof Error ? err.message : String(err)); + } finally { + oauthInProgressRef.current = false; + } + }, [selectedInspectorClient]); + + const handleRunGuidedToCompletion = useCallback(async () => { + if ( + !selectedServer || + !selectedInspectorClient || + !selectedServerConfig || + !isOAuthCapableServer(selectedServerConfig) + ) { + return; + } + if (oauthInProgressRef.current) return; + oauthInProgressRef.current = true; + setOauthStatus("authenticating"); + setOauthMessage(null); + tuiLogger.info( + { server: selectedServer }, + "OAuth authentication started (Run Guided Auth to completion)", + ); + + const ensureCallbackServer = async () => { + if (callbackServerRef.current) return; + const callbackServer = createOAuthCallbackServer(); + callbackServerRef.current = callbackServer; + const { redirectUrl } = await callbackServer.start({ + ...callbackServerBaseOptions, + onCallback: async (params) => { + try { + await selectedInspectorClient!.completeOAuthFlow(params.code); + setOauthStatus("success"); + setOauthMessage("OAuth complete. Press C to connect."); + } catch (err) { + setOauthStatus("error"); + setOauthMessage(err instanceof Error ? err.message : String(err)); + } finally { + callbackServerRef.current = null; + } + }, + onError: (params) => { + setOauthStatus("error"); + setOauthMessage( + params.error_description ?? params.error ?? "OAuth error", + ); + void callbackServer.stop(); + callbackServerRef.current = null; + }, + }); + const redirectUrlProvider = + redirectUrlProvidersRef.current[selectedServer]; + if (redirectUrlProvider) { + redirectUrlProvider.redirectUrl = redirectUrl; + } + }; + + try { + await ensureCallbackServer(); + const authUrl = await selectedInspectorClient.runGuidedAuth(); + if (authUrl) { + await openUrl(authUrl); + } + setOauthStatus("idle"); + } catch (err) { + setOauthStatus("error"); + setOauthMessage(err instanceof Error ? err.message : String(err)); + } finally { + oauthInProgressRef.current = false; + } + }, [ + selectedServer, + selectedInspectorClient, + selectedServerConfig, + callbackServerBaseOptions, + ]); + + const handleClearOAuth = useCallback(() => { + if (selectedInspectorClient) { + selectedInspectorClient.clearOAuthTokens(); + setOauthStatus("idle"); + setOauthMessage(null); + } + }, [selectedInspectorClient]); + + // Build current server state from InspectorClient data (tools from ManagedToolsState) + const currentServerState = useMemo(() => { + if (!selectedServer) return null; + return { + status: inspectorStatus, + error: null, // InspectorClient doesn't track error in state, only emits error events + capabilities: inspectorCapabilities, + serverInfo: inspectorServerInfo, + instructions: inspectorInstructions, + resources: managedResources, + resourceTemplates: managedResourceTemplates, + prompts: managedPrompts, + tools: managedTools, + stderrLogs: inspectorStderrLogs, // InspectorClient manages this + }; + }, [ + selectedServer, + inspectorStatus, + inspectorCapabilities, + inspectorServerInfo, + inspectorInstructions, + managedResources, + managedResourceTemplates, + managedPrompts, + managedTools, + inspectorStderrLogs, + ]); + + // 401 on connect → prompt to authenticate (HTTP servers). Hide during/after auth. + const show401AuthHint = useMemo(() => { + if (inspectorStatus !== "error") return false; + if (oauthStatus === "authenticating" || oauthStatus === "success") + return false; + if (!selectedServerConfig || !isOAuthCapableServer(selectedServerConfig)) + return false; + return inspectorFetchRequests.some((r) => r.responseStatus === 401); + }, [ + inspectorStatus, + oauthStatus, + selectedServerConfig, + inspectorFetchRequests, + ]); + + // Helper functions to render details modal content + const renderResourceDetails = ( + resource: + | Resource + | { + content: import("@modelcontextprotocol/sdk/types.js").ReadResourceResult; + }, + ) => ( + <> + {"uri" in resource && resource.description && ( + <> + {resource.description.split("\n").map((line: string, idx: number) => ( + + {line} + + ))} + + )} + {"uri" in resource && resource.uri && ( + + URI: + + {resource.uri} + + + )} + {"mimeType" in resource && resource.mimeType && ( + + MIME Type: + + {resource.mimeType} + + + )} + + Full JSON: + + {JSON.stringify(resource, null, 2)} + + + + ); + + const renderPromptDetails = ( + prompt: Prompt & { result?: GetPromptResult }, + ) => ( + <> + {prompt.description && ( + <> + {prompt.description.split("\n").map((line: string, idx: number) => ( + + {line} + + ))} + + )} + {prompt.arguments && prompt.arguments.length > 0 && ( + <> + + Arguments: + + {prompt.arguments.map((arg: PromptArgument, idx: number) => ( + + + - {arg.name}:{" "} + {arg.description ?? (arg as { type?: string }).type ?? "string"} + + + ))} + + )} + + Full JSON: + + {JSON.stringify(prompt, null, 2)} + + + + ); + + const renderToolDetails = (tool: Tool) => ( + <> + {tool.description && ( + <> + {tool.description.split("\n").map((line: string, idx: number) => ( + + {line} + + ))} + + )} + {tool.inputSchema && ( + + Input Schema: + + {JSON.stringify(tool.inputSchema, null, 2)} + + + )} + + Full JSON: + + {JSON.stringify(tool, null, 2)} + + + + ); + + const renderRequestDetails = (request: FetchRequestEntry) => ( + <> + + + {request.method} {request.url} + + + + + Category:{" "} + {request.category === "auth" ? "auth" : "transport"} + + + {request.responseStatus !== undefined ? ( + + + Status: {request.responseStatus} {request.responseStatusText || ""} + + + ) : request.error ? ( + + + Error: {request.error} + + + ) : null} + {request.duration !== undefined && ( + + + {request.timestamp.toLocaleTimeString()} ({request.duration}ms) + + + )} + + Request Headers: + {Object.entries(request.requestHeaders).map(([key, value]) => ( + + + {key}: {value} + + + ))} + + {request.requestBody && ( + <> + + Request Body: + + {(() => { + try { + const parsed = JSON.parse(request.requestBody); + return JSON.stringify(parsed, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + )); + } catch { + return ( + + {request.requestBody} + + ); + } + })()} + + )} + {request.responseHeaders && + Object.keys(request.responseHeaders).length > 0 && ( + <> + + Response Headers: + + {Object.entries(request.responseHeaders).map(([key, value]) => ( + + + {key}: {value} + + + ))} + + )} + {request.responseBody && ( + <> + + Response Body: + + {(() => { + try { + const parsed = JSON.parse(request.responseBody); + return JSON.stringify(parsed, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + )); + } catch { + return ( + + {request.responseBody} + + ); + } + })()} + + )} + + ); + + const renderMessageDetails = (message: MessageEntry) => ( + <> + + Direction: {message.direction} + + + + {message.timestamp.toLocaleTimeString()} + {message.duration !== undefined && ` (${message.duration}ms)`} + + + {message.direction === "request" ? ( + <> + + Request: + + {JSON.stringify(message.message, null, 2)} + + + {message.response && ( + + Response: + + + {JSON.stringify(message.response, null, 2)} + + + + )} + + ) : ( + + + {message.direction === "response" ? "Response:" : "Notification:"} + + + {JSON.stringify(message.message, null, 2)} + + + )} + + ); + + // Update tab counts when selected server changes or InspectorClient state changes + // Just reflect InspectorClient state - don't try to be clever + useEffect(() => { + if (!selectedServer) { + return; + } + + setTabCounts({ + resources: managedResources.length || 0, + prompts: managedPrompts.length || 0, + tools: managedTools.length || 0, + messages: inspectorMessages.length || 0, + requests: inspectorFetchRequests.length || 0, + logging: inspectorStderrLogs.length || 0, + }); + }, [ + selectedServer, + managedResources, + managedPrompts, + managedTools, + inspectorMessages, + inspectorFetchRequests, + inspectorStderrLogs, + ]); + + // Set focus to the default for the active tab whenever the tab changes + useEffect(() => { + if (activeTab === "messages") { + setFocus("messagesList"); + } else if (activeTab === "requests") { + setFocus("requestsList"); + } else { + setFocus("tabContentList"); + } + }, [activeTab]); + + // Switch away from logging tab if server is not stdio + useEffect(() => { + if (activeTab === "logging" && selectedServer) { + const client = inspectorClients[selectedServer]; + if (client && client.getServerType() !== "stdio") { + setActiveTab("info"); + } + } + }, [selectedServer, activeTab, inspectorClients]); + + useInput((input: string, key: Key) => { + // Don't process input when modal is open + if (toolTestModal || resourceTestModal || promptTestModal || detailsModal) { + return; + } + + if (key.ctrl && input === "c") { + exit(); + } + + // Exit accelerators + if (key.escape) { + exit(); + } + + // G/Q/S: switch to Auth tab (if not already) and select Guided/Quick/Clear + const showAuthTabForAccel = + !!selectedServer && + !!selectedServerConfig && + isOAuthCapableServer(selectedServerConfig); + const lower = input.toLowerCase(); + if ( + showAuthTabForAccel && + (lower === "g" || lower === "q" || lower === "s") + ) { + setActiveTab("auth"); + setFocus("tabContentList"); + setSelectedAuthAction( + lower === "g" ? "guided" : lower === "q" ? "quick" : "clear", + ); + return; + } + + // Tab switching with accelerator keys (first character of tab name) + const showAuthTab = + !!selectedServer && + !!selectedServerConfig && + isOAuthCapableServer(selectedServerConfig); + const showLoggingTab = + !!selectedServer && + inspectorClients[selectedServer]?.getServerType() === "stdio"; + const showRequestsTab = + !!selectedServer && + (inspectorClients[selectedServer]?.getServerType() === "sse" || + inspectorClients[selectedServer]?.getServerType() === + "streamable-http"); + const tabAccelerators: Record = Object.fromEntries( + tabList + .filter((tab: { id: TabType }) => { + if (tab.id === "auth" && !showAuthTab) return false; + if (tab.id === "logging" && !showLoggingTab) return false; + if (tab.id === "requests" && !showRequestsTab) return false; + return true; + }) + .map((tab: { id: TabType; label: string; accelerator: string }) => [ + tab.accelerator, + tab.id, + ]), + ); + if (tabAccelerators[input.toLowerCase()]) { + setActiveTab(tabAccelerators[input.toLowerCase()]); + setFocus("tabs"); + } else if (key.tab && !key.shift) { + // Flat focus order: servers -> tabs -> list -> details -> wrap to servers + const focusOrder: FocusArea[] = + activeTab === "messages" + ? ["serverList", "tabs", "messagesList", "messagesDetail"] + : activeTab === "requests" + ? ["serverList", "tabs", "requestsList", "requestsDetail"] + : ["serverList", "tabs", "tabContentList", "tabContentDetails"]; + const currentIndex = focusOrder.indexOf(focus); + const nextIndex = (currentIndex + 1) % focusOrder.length; + setFocus(focusOrder[nextIndex]); + } else if (key.tab && key.shift) { + // Reverse order: servers <- tabs <- list <- details <- wrap to servers + const focusOrder: FocusArea[] = + activeTab === "messages" + ? ["serverList", "tabs", "messagesList", "messagesDetail"] + : activeTab === "requests" + ? ["serverList", "tabs", "requestsList", "requestsDetail"] + : ["serverList", "tabs", "tabContentList", "tabContentDetails"]; + const currentIndex = focusOrder.indexOf(focus); + const prevIndex = + currentIndex > 0 ? currentIndex - 1 : focusOrder.length - 1; + setFocus(focusOrder[prevIndex]); + } else if (key.upArrow || key.downArrow) { + // Arrow keys only work in the focused pane + if (focus === "serverList") { + // Arrow key navigation for server list + if (key.upArrow) { + if (selectedServer === null) { + setSelectedServer(serverNames[serverNames.length - 1] || null); + } else { + const currentIndex = serverNames.indexOf(selectedServer); + const newIndex = + currentIndex > 0 ? currentIndex - 1 : serverNames.length - 1; + setSelectedServer(serverNames[newIndex] || null); + } + } else if (key.downArrow) { + if (selectedServer === null) { + setSelectedServer(serverNames[0] || null); + } else { + const currentIndex = serverNames.indexOf(selectedServer); + const newIndex = + currentIndex < serverNames.length - 1 ? currentIndex + 1 : 0; + setSelectedServer(serverNames[newIndex] || null); + } + } + return; // Handled, don't let other handlers process + } + // If focus is on tabs, tabContentList, tabContentDetails, messagesList, or messagesDetail, + // arrow keys will be handled by those components - don't do anything here + } else if (focus === "tabs" && (key.leftArrow || key.rightArrow)) { + // Left/Right arrows switch tabs when tabs are focused + const showAuthTab = + !!selectedServer && + !!selectedServerConfig && + isOAuthCapableServer(selectedServerConfig); + const showLoggingTab = + !!selectedServer && + inspectorClients[selectedServer]?.getServerType() === "stdio"; + const showRequestsTab = + !!selectedServer && + (inspectorClients[selectedServer]?.getServerType() === "sse" || + inspectorClients[selectedServer]?.getServerType() === + "streamable-http"); + const allTabs: TabType[] = [ + "info", + "auth", + "resources", + "prompts", + "tools", + "messages", + "requests", + "logging", + ]; + const tabs = allTabs.filter((t) => { + if (t === "auth" && !showAuthTab) return false; + if (t === "logging" && !showLoggingTab) return false; + if (t === "requests" && !showRequestsTab) return false; + return true; + }); + const currentIndex = tabs.indexOf(activeTab); + if (key.leftArrow) { + const newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1; + setActiveTab(tabs[newIndex]); + } else if (key.rightArrow) { + const newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0; + setActiveTab(tabs[newIndex]); + } + } + + // Accelerator keys for connect/disconnect (work from anywhere) + // 'a' switches to Auth tab; use the Auth tab for Quick/Guided auth + if (selectedServer) { + if ( + input.toLowerCase() === "c" && + (inspectorStatus === "disconnected" || inspectorStatus === "error") + ) { + handleConnect(); + } else if ( + input.toLowerCase() === "d" && + (inspectorStatus === "connected" || inspectorStatus === "connecting") + ) { + handleDisconnect(); + } + } + }); + + // Calculate layout dimensions + const headerHeight = 1; + const tabsHeight = 1; + // Server details will be flexible - calculate remaining space for content + const availableHeight = dimensions.height - headerHeight - tabsHeight; + // Reserve space for server details (will grow as needed, but we'll use flexGrow) + const serverDetailsMinHeight = 3; + const contentHeight = availableHeight - serverDetailsMinHeight; + const serverListWidth = Math.floor(dimensions.width * 0.3); + const contentWidth = dimensions.width - serverListWidth; + + const getStatusColor = (status: string) => { + switch (status) { + case "connected": + return "green"; + case "connecting": + return "yellow"; + case "error": + return "red"; + default: + return "gray"; + } + }; + + const getStatusSymbol = (status: string) => { + switch (status) { + case "connected": + return "ā—"; + case "connecting": + return "◐"; + case "error": + return "āœ—"; + default: + return "ā—‹"; + } + }; + + return ( + + {/* Header row across the top */} + + + + {packageJson.name} + + - {packageJson.description} + + v{packageJson.version} + + + {/* Main content area */} + + {/* Left column - Server list */} + + + + MCP Servers + + + + {serverNames.map((serverName) => { + const isSelected = selectedServer === serverName; + return ( + + + {isSelected ? "ā–¶ " : " "} + {serverName} + + + ); + })} + + + {/* Fixed footer */} + + + ESC to exit + + + + + {/* Right column - Server details, Tabs and content */} + + {/* Server Details - Flexible height */} + + + + + {selectedServer} + + + {currentServerState && ( + <> + + {getStatusSymbol(currentServerState.status)}{" "} + {currentServerState.status} + + + {(currentServerState?.status === "disconnected" || + currentServerState?.status === "error") && ( + + [Connect] + + )} + {(currentServerState?.status === "connected" || + currentServerState?.status === "connecting") && ( + + [Disconnect] + + )} + + )} + + + {show401AuthHint && ( + + + 401 Unauthorized. Press A to authenticate. + + + )} + {oauthStatus !== "idle" && ( + + {oauthStatus === "authenticating" && ( + OAuth: authenticating… + )} + {oauthStatus === "success" && oauthMessage && ( + {oauthMessage} + )} + {oauthStatus === "error" && oauthMessage && ( + OAuth: {oauthMessage} + )} + + )} + + + + {/* Tabs */} + { + const serverType = + inspectorClients[selectedServer].getServerType(); + return ( + serverType === "sse" || serverType === "streamable-http" + ); + })() + : false + } + /> + + {/* Tab Content */} + + {activeTab === "info" && ( + + )} + {activeTab === "auth" && + selectedServer && + selectedServerConfig && + isOAuthCapableServer(selectedServerConfig) ? ( + + ) : null} + {activeTab === "resources" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, resources: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(resource) => + setDetailsModal({ + title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, + content: renderResourceDetails(resource), + }) + } + onFetchResource={() => { + // Resource fetching is handled internally by ResourcesTab + // This callback is just for triggering the fetch + }} + onFetchTemplate={(template) => { + setResourceTestModal({ + template, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!(toolTestModal || resourceTestModal || detailsModal) + } + /> + ) : activeTab === "prompts" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, prompts: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(prompt) => + setDetailsModal({ + title: `Prompt: ${prompt.name || "Unknown"}`, + content: renderPromptDetails(prompt), + }) + } + onFetchPrompt={(prompt) => { + setPromptTestModal({ + prompt, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!( + toolTestModal || + resourceTestModal || + promptTestModal || + detailsModal + ) + } + /> + ) : activeTab === "tools" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, tools: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onTestTool={(tool) => + setToolTestModal({ + tool, + inspectorClient: selectedInspectorClient, + }) + } + onViewDetails={(tool) => + setDetailsModal({ + title: `Tool: ${tool.name || "Unknown"}`, + content: renderToolDetails(tool), + }) + } + modalOpen={!!(toolTestModal || detailsModal)} + /> + ) : activeTab === "messages" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, messages: count })) + } + focusedPane={ + focus === "messagesDetail" + ? "details" + : focus === "messagesList" + ? "messages" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(message) => { + const label = + message.direction === "request" && + "method" in message.message + ? message.message.method + : message.direction === "response" + ? "Response" + : message.direction === "notification" && + "method" in message.message + ? message.message.method + : "Message"; + setDetailsModal({ + title: `Message: ${label}`, + content: renderMessageDetails(message), + }); + }} + /> + ) : activeTab === "requests" && + selectedInspectorClient && + (inspectorStatus === "connected" || + inspectorFetchRequests.length > 0) ? ( + + setTabCounts((prev) => ({ ...prev, requests: count })) + } + focusedPane={ + focus === "requestsDetail" + ? "details" + : focus === "requestsList" + ? "requests" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(request) => { + setDetailsModal({ + title: `Request: ${request.method} ${request.url}`, + content: renderRequestDetails(request), + }); + }} + /> + ) : activeTab === "logging" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, logging: count })) + } + focused={ + focus === "tabContentList" || focus === "tabContentDetails" + } + /> + ) : null} + + + + + {/* Tool Test Modal - rendered at App level for full screen overlay */} + {toolTestModal && ( + setToolTestModal(null)} + /> + )} + + {/* Resource Test Modal - rendered at App level for full screen overlay */} + {resourceTestModal && ( + setResourceTestModal(null)} + /> + )} + + {promptTestModal && ( + setPromptTestModal(null)} + /> + )} + + {/* Details Modal - rendered at App level for full screen overlay */} + {detailsModal && ( + setDetailsModal(null)} + /> + )} + + ); +} + +export default App; diff --git a/tui/src/components/AuthTab.tsx b/tui/src/components/AuthTab.tsx new file mode 100644 index 000000000..eb05f2ef0 --- /dev/null +++ b/tui/src/components/AuthTab.tsx @@ -0,0 +1,427 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { SelectableItem } from "./SelectableItem.js"; +import type { + MCPServerConfig, + InspectorClient, +} from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { + AuthGuidedState, + OAuthStep, +} from "@modelcontextprotocol/inspector-core/auth"; + +const STEP_LABELS: Record = { + metadata_discovery: "Metadata Discovery", + client_registration: "Client Registration", + authorization_redirect: "Preparing Authorization", + authorization_code: "Request Authorization Code", + token_request: "Token Request", + complete: "Authentication Complete", +}; + +const STEP_ORDER: OAuthStep[] = [ + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", +]; + +function stepIndex(step: OAuthStep): number { + const i = STEP_ORDER.indexOf(step); + return i >= 0 ? i : 0; +} + +interface AuthTabProps { + serverName: string | null; + serverConfig: MCPServerConfig | null; + inspectorClient: InspectorClient | null; + oauthStatus: "idle" | "authenticating" | "success" | "error"; + oauthMessage: string | null; + width: number; + height: number; + focused?: boolean; + selectedAction: "guided" | "quick" | "clear"; + onSelectedActionChange: (action: "guided" | "quick" | "clear") => void; + onQuickAuth: () => Promise; + onGuidedStart: () => Promise; + onGuidedAdvance: () => Promise; + onRunGuidedToCompletion: () => Promise; + onClearOAuth: () => void; + isOAuthCapable: boolean; +} + +export function AuthTab({ + serverName, + inspectorClient, + oauthStatus, + oauthMessage, + width, + height, + focused = false, + selectedAction, + onSelectedActionChange, + onQuickAuth, + onGuidedStart, + onGuidedAdvance, + onRunGuidedToCompletion, + onClearOAuth, + isOAuthCapable, +}: AuthTabProps) { + const scrollViewRef = useRef(null); + const [oauthState, setOauthState] = useState( + undefined, + ); + const [guidedStarted, setGuidedStarted] = useState(false); + const [clearedConfirmation, setClearedConfirmation] = useState(false); + + // Sync oauthState from InspectorClient + useEffect(() => { + if (!inspectorClient) { + setOauthState(undefined); + setGuidedStarted(false); + return; + } + + const update = () => setOauthState(inspectorClient.getOAuthState()); + update(); + + const onStepChange = () => update(); + inspectorClient.addEventListener("oauthStepChange", onStepChange); + inspectorClient.addEventListener("oauthComplete", onStepChange); + return () => { + inspectorClient.removeEventListener("oauthStepChange", onStepChange); + inspectorClient.removeEventListener("oauthComplete", onStepChange); + }; + }, [inspectorClient]); + + // Reset guided state when switching servers + useEffect(() => { + setGuidedStarted(false); + }, [serverName]); + + // Clear confirmation when switching away from Clear menu item + useEffect(() => { + if (selectedAction !== "clear") { + setClearedConfirmation(false); + } + }, [selectedAction]); + + const guidedFlowStarted = !!oauthState?.oauthStep; + const currentStep = oauthState?.oauthStep ?? "metadata_discovery"; + const needsAuthCode = + currentStep === "authorization_code" && oauthState?.authorizationUrl; + const isComplete = currentStep === "complete"; + + const handleContinue = useCallback(async () => { + if (!guidedStarted) { + await onGuidedStart(); + setGuidedStarted(true); + } else if (!needsAuthCode && !isComplete) { + await onGuidedAdvance(); + } + }, [ + guidedStarted, + needsAuthCode, + isComplete, + onGuidedStart, + onGuidedAdvance, + ]); + + // Keyboard: G/Q/S select menu item (handled by App when not focused), + // left/right select, Enter run, up/down scroll + useInput( + (input: string, key: Key) => { + if (!focused || !isOAuthCapable) return; + + const lower = input.toLowerCase(); + if (lower === "g") { + onSelectedActionChange("guided"); + return; + } + if (lower === "q") { + onSelectedActionChange("quick"); + return; + } + if (lower === "s") { + onSelectedActionChange("clear"); + return; + } + + if (key.leftArrow) { + onSelectedActionChange( + selectedAction === "guided" + ? "clear" + : selectedAction === "quick" + ? "guided" + : "quick", + ); + } else if (key.rightArrow) { + onSelectedActionChange( + selectedAction === "guided" + ? "quick" + : selectedAction === "quick" + ? "clear" + : "guided", + ); + } else if (key.upArrow && scrollViewRef.current) { + scrollViewRef.current.scrollBy(-1); + } else if (key.downArrow && scrollViewRef.current) { + scrollViewRef.current.scrollBy(1); + } else if (key.pageUp && scrollViewRef.current) { + const h = scrollViewRef.current.getViewportHeight() || 1; + scrollViewRef.current.scrollBy(-h); + } else if (key.pageDown && scrollViewRef.current) { + const h = scrollViewRef.current.getViewportHeight() || 1; + scrollViewRef.current.scrollBy(h); + } else if (key.return) { + if (selectedAction === "guided") onRunGuidedToCompletion(); + else if (selectedAction === "quick") onQuickAuth(); + else if (selectedAction === "clear") { + onClearOAuth(); + setClearedConfirmation(true); + } + } else if (input === " " && selectedAction === "guided") { + handleContinue(); + } + }, + { + isActive: focused, + }, + ); + + if (!serverName || !isOAuthCapable) { + return ( + + + Select an OAuth-capable server (SSE or Streamable HTTP) to configure + authentication. + + + ); + } + + return ( + + + + Authentication + + + + {/* Action bar and hint - single container for tight spacing */} + + + + Guided Auth + + + Quick Auth + + + Clear OAuth State + + + + {selectedAction === "guided" && ( + <> + + Press [Space] to advance one step through guided auth. + + + Press [Enter] to run guided auth to completion. + + + )} + {selectedAction === "quick" && ( + Press [Enter] to run quick auth. + )} + {selectedAction === "clear" && ( + Press [Enter] to clear OAuth state. + )} + + + + {selectedAction === "guided" && ( + + Guided OAuth Flow Progress + {STEP_ORDER.map((step) => { + const stepIdx = stepIndex(step); + const currentIdx = stepIndex(currentStep); + const completed = + guidedFlowStarted && + (stepIdx < currentIdx || + (step === currentStep && isComplete)); + const inProgress = + guidedFlowStarted && step === currentStep && !isComplete; + const details = oauthState + ? getStepDetails(oauthState, step) + : null; + + const icon = completed ? "āœ“" : inProgress ? "→" : "ā—‹"; + const color = completed + ? "green" + : inProgress + ? "cyan" + : "gray"; + + return ( + + + {icon} {STEP_LABELS[step]} + {inProgress && " (in progress)"} + + {completed && details && ( + + {details} + + )} + {inProgress && details && ( + + {details} + + )} + + ); + })} + + {/* Waiting for auth - URL was opened when we reached this step */} + {oauthState && needsAuthCode && oauthState?.authorizationUrl && ( + + Authorization URL opened in browser + + + {oauthState.authorizationUrl.toString()} + + + + + Complete authorization in the browser. You will be + redirected and the flow will complete automatically. + + + + )} + + )} + + {selectedAction === "quick" && ( + + {oauthStatus === "authenticating" && ( + Authenticating... + )} + {oauthStatus === "error" && oauthMessage && ( + {oauthMessage} + )} + {oauthStatus === "success" && + oauthState && + oauthState.authType === "normal" && + (oauthState.oauthTokens || oauthState.oauthClientInfo) && ( + <> + Quick Auth Results + {oauthState.oauthClientInfo && ( + + + Client:{" "} + {JSON.stringify(oauthState.oauthClientInfo, null, 2)} + + + )} + {oauthState.oauthTokens && ( + + + Access Token:{" "} + {oauthState.oauthTokens.access_token?.slice(0, 20)}... + + + )} + + )} + + )} + + {selectedAction === "clear" && clearedConfirmation && ( + + OAuth state cleared. + + )} + + + + {focused && ( + + + ←/→ select, G/Q/S or Enter run, ↑/↓ scroll + + + )} + + ); +} + +function getStepDetails( + state: AuthGuidedState, + step: OAuthStep, +): string | null { + switch (step) { + case "metadata_discovery": + if (state.resourceMetadata || state.oauthMetadata) { + const parts: string[] = []; + if (state.resourceMetadata) { + parts.push( + `Resource: ${JSON.stringify(state.resourceMetadata, null, 2)}`, + ); + } + if (state.oauthMetadata) { + parts.push(`OAuth: ${JSON.stringify(state.oauthMetadata, null, 2)}`); + } + return parts.join("\n"); + } + return null; + case "client_registration": + if (state.oauthClientInfo) { + return JSON.stringify(state.oauthClientInfo, null, 2); + } + return null; + case "authorization_redirect": + if (state.authorizationUrl) { + return `URL: ${state.authorizationUrl.toString()}`; + } + return null; + case "authorization_code": + return state.authorizationCode + ? `Code received: ${state.authorizationCode.slice(0, 10)}...` + : null; + case "token_request": + return "Exchanging code for tokens..."; + case "complete": + if (state.oauthTokens) { + return `Tokens: access_token=${state.oauthTokens.access_token?.slice(0, 15)}...`; + } + return null; + default: + return null; + } +} diff --git a/tui/src/components/DetailsModal.tsx b/tui/src/components/DetailsModal.tsx new file mode 100644 index 000000000..944eb6438 --- /dev/null +++ b/tui/src/components/DetailsModal.tsx @@ -0,0 +1,106 @@ +import React, { useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { ModalBackdrop } from "./ModalBackdrop.js"; + +interface DetailsModalProps { + title: string; + content: React.ReactNode; + width: number; + height: number; + onClose: () => void; +} + +export function DetailsModal({ + title, + content, + width, + height, + onClose, +}: DetailsModalProps) { + const scrollViewRef = useRef(null); + + // Use full terminal dimensions + const [terminalDimensions, setTerminalDimensions] = React.useState({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + + React.useEffect(() => { + const updateDimensions = () => { + setTerminalDimensions({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + }; + process.stdout.on("resize", updateDimensions); + updateDimensions(); + return () => { + process.stdout.off("resize", updateDimensions); + }; + }, [width, height]); + + // Handle escape to close and scrolling + useInput( + (input: string, key: Key) => { + if (key.escape) { + onClose(); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.pageDown) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } else if (key.pageUp) { + const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } + }, + { isActive: true }, + ); + + // Calculate modal dimensions - use almost full screen + const modalWidth = terminalDimensions.width - 2; + const modalHeight = terminalDimensions.height - 2; + + return ( + + + {/* Modal Content */} + + {/* Header */} + + + {title} + + + (Press ESC to close) + + + {/* Content Area */} + + {content} + + + + ); +} diff --git a/tui/src/components/HistoryTab.tsx b/tui/src/components/HistoryTab.tsx new file mode 100644 index 000000000..8d3b92a8b --- /dev/null +++ b/tui/src/components/HistoryTab.tsx @@ -0,0 +1,321 @@ +import React, { useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { MessageEntry } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +interface HistoryTabProps { + serverName: string | null; + messages: MessageEntry[]; + width: number; + height: number; + onCountChange?: (count: number) => void; + focusedPane?: "messages" | "details" | null; + onViewDetails?: (message: MessageEntry) => void; + modalOpen?: boolean; +} + +export function HistoryTab({ + messages, + width, + height, + onCountChange, + focusedPane = null, + onViewDetails, + modalOpen = false, +}: HistoryTabProps) { + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + messages.length, + visibleCount, + ); + const scrollViewRef = useRef(null); + const selectedMessage = messages[selectedIndex] || null; + + // Handle arrow key navigation and scrolling when focused + useInput( + (input: string, key: Key) => { + if (focusedPane === "messages") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < messages.length - 1) { + setSelection(selectedIndex + 1); + } else if (key.pageUp) { + setSelection(Math.max(0, selectedIndex - visibleCount)); + } else if (key.pageDown) { + setSelection( + Math.min(messages.length - 1, selectedIndex + visibleCount), + ); + } + return; + } + + // details scrolling (only when details pane is focused) + if (focusedPane === "details") { + // Handle '+' key to view in full screen modal + if (input === "+" && selectedMessage && onViewDetails) { + onViewDetails(selectedMessage); + return; + } + + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { isActive: !modalOpen && focusedPane !== undefined }, + ); + + // Update count when messages change + React.useEffect(() => { + onCountChange?.(messages.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]); + + // Reset details scroll when message selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + return ( + + {/* Left column - Messages list */} + + + + Messages ({messages.length}) + + + + {/* Messages list */} + {messages.length === 0 ? ( + + No messages + + ) : ( + + {messages + .slice(firstVisible, firstVisible + visibleCount) + .map((msg, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + let label: string; + if (msg.direction === "request" && "method" in msg.message) { + label = msg.message.method; + } else if (msg.direction === "response") { + if ("result" in msg.message) { + label = "Response (result)"; + } else if ("error" in msg.message) { + label = `Response (error: ${msg.message.error.code})`; + } else { + label = "Response"; + } + } else if ( + msg.direction === "notification" && + "method" in msg.message + ) { + label = msg.message.method; + } else { + label = "Unknown"; + } + const direction = + msg.direction === "request" + ? "→" + : msg.direction === "response" + ? "←" + : "•"; + const hasResponse = msg.response !== undefined; + + return ( + + + {isSelected ? "ā–¶ " : " "} + {direction} {label} + {hasResponse + ? " āœ“" + : msg.direction === "request" + ? " ..." + : ""} + + + ); + })} + + )} + + + {/* Right column - Message details */} + + {selectedMessage ? ( + <> + {/* Fixed method caption only */} + + + {selectedMessage.direction === "request" && + "method" in selectedMessage.message + ? selectedMessage.message.method + : selectedMessage.direction === "response" + ? "Response" + : selectedMessage.direction === "notification" && + "method" in selectedMessage.message + ? selectedMessage.message.method + : "Message"} + + + + {/* Scrollable content area */} + + {/* Metadata */} + + Direction: {selectedMessage.direction} + + + {selectedMessage.timestamp.toLocaleTimeString()} + {selectedMessage.duration !== undefined && + ` (${selectedMessage.duration}ms)`} + + + + + {selectedMessage.direction === "request" ? ( + <> + {/* Request label */} + + Request: + + + {/* Request content */} + {JSON.stringify(selectedMessage.message, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + {/* Response section */} + {selectedMessage.response ? ( + <> + + Response: + + {JSON.stringify(selectedMessage.response, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + ) : ( + + + Waiting for response... + + + )} + + ) : ( + <> + {/* Response or notification label */} + + + {selectedMessage.direction === "response" + ? "Response:" + : "Notification:"} + + + + {/* Message content */} + {JSON.stringify(selectedMessage.message, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + ↑/↓ to scroll, + to zoom + + + )} + + ) : ( + + Select a message to view details + + )} + + + ); +} diff --git a/tui/src/components/InfoTab.tsx b/tui/src/components/InfoTab.tsx new file mode 100644 index 000000000..486d9181a --- /dev/null +++ b/tui/src/components/InfoTab.tsx @@ -0,0 +1,221 @@ +import React, { useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { + MCPServerConfig, + ServerState, +} from "@modelcontextprotocol/inspector-core/mcp/index.js"; + +interface InfoTabProps { + serverName: string | null; + serverConfig: MCPServerConfig | null; + serverState: ServerState | null; + width: number; + height: number; + focused?: boolean; +} + +export function InfoTab({ + serverName, + serverConfig, + serverState, + width, + height, + focused = false, +}: InfoTabProps) { + const scrollViewRef = useRef(null); + + // Handle keyboard input for scrolling + useInput( + (input: string, key: Key) => { + if (focused) { + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { isActive: focused }, + ); + + return ( + + + + Info + + + + {serverName ? ( + <> + {/* Scrollable content area - takes remaining space */} + + + {/* Server Configuration */} + + Server Configuration + + {serverConfig ? ( + + {serverConfig.type === undefined || + serverConfig.type === "stdio" ? ( + <> + Type: stdio + Command: {serverConfig.command} + {serverConfig.args && serverConfig.args.length > 0 && ( + + Args: + {serverConfig.args.map((arg: string, idx: number) => ( + + {arg} + + ))} + + )} + {serverConfig.env && + Object.keys(serverConfig.env).length > 0 && ( + + + Env:{" "} + {Object.entries(serverConfig.env) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )} + {serverConfig.cwd && ( + + CWD: {serverConfig.cwd} + + )} + + ) : serverConfig.type === "sse" ? ( + <> + Type: sse + URL: {serverConfig.url} + {serverConfig.headers && + Object.keys(serverConfig.headers).length > 0 && ( + + + Headers:{" "} + {Object.entries(serverConfig.headers) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )} + + ) : serverConfig.type === "streamable-http" ? ( + <> + Type: streamable-http + URL: {serverConfig.url} + {serverConfig.headers && + Object.keys(serverConfig.headers).length > 0 && ( + + + Headers:{" "} + {Object.entries(serverConfig.headers) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + )} + + ) : null} + + ) : ( + + No configuration available + + )} + + {/* Server Info */} + {serverState && + serverState.status === "connected" && + serverState.serverInfo && ( + <> + + Server Information + + + {serverState.serverInfo.name && ( + + Name: {serverState.serverInfo.name} + + )} + {serverState.serverInfo.version && ( + + + Version: {serverState.serverInfo.version} + + + )} + {serverState.instructions && ( + + Instructions: + + {serverState.instructions} + + + )} + + + )} + + {serverState && serverState.status === "error" && ( + + + Error + + {serverState.error && ( + + {serverState.error} + + )} + + )} + + {serverState && serverState.status === "disconnected" && ( + + Server not connected + + )} + + + + {/* Fixed keyboard help footer at bottom - only show when focused */} + {focused && ( + + + ↑/↓ to scroll, + to zoom + + + )} + + ) : null} + + ); +} diff --git a/tui/src/components/ModalBackdrop.tsx b/tui/src/components/ModalBackdrop.tsx new file mode 100644 index 000000000..6062f56f5 --- /dev/null +++ b/tui/src/components/ModalBackdrop.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Box, Text } from "ink"; + +/** + * Fills a region with a solid background color using Text (Ink 5 Box has no backgroundColor). + * Use as the first child behind modal content so the dialog obscures the page. + */ +export function ModalBackdrop({ + width, + height, + color = "black", +}: { + width: number; + height: number; + color?: string; +}) { + const line = " ".repeat(Math.max(0, width)); + return ( + + {Array.from({ length: height }, (_, i) => ( + + {line} + + ))} + + ); +} diff --git a/tui/src/components/NotificationsTab.tsx b/tui/src/components/NotificationsTab.tsx new file mode 100644 index 000000000..512553ad3 --- /dev/null +++ b/tui/src/components/NotificationsTab.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { StderrLogEntry } from "@modelcontextprotocol/inspector-core/mcp/index.js"; + +interface NotificationsTabProps { + stderrLogs: StderrLogEntry[]; + width: number; + height: number; + onCountChange?: (count: number) => void; + focused?: boolean; +} + +export function NotificationsTab({ + stderrLogs, + width, + height, + onCountChange, + focused = false, +}: NotificationsTabProps) { + const scrollViewRef = useRef(null); + const onCountChangeRef = useRef(onCountChange); + + // Update ref when callback changes + useEffect(() => { + onCountChangeRef.current = onCountChange; + }, [onCountChange]); + + useEffect(() => { + onCountChangeRef.current?.(stderrLogs.length); + }, [stderrLogs.length]); + + // Handle keyboard input for scrolling + useInput( + (input: string, key: Key) => { + if (focused) { + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { isActive: focused }, + ); + + return ( + + + + Logging ({stderrLogs.length}) + + + {stderrLogs.length === 0 ? ( + + No stderr output yet + + ) : ( + + {stderrLogs.map((log, index) => ( + + [{log.timestamp.toLocaleTimeString()}] + {log.message} + + ))} + + )} + + ); +} diff --git a/tui/src/components/PromptTestModal.tsx b/tui/src/components/PromptTestModal.tsx new file mode 100644 index 000000000..532c912a8 --- /dev/null +++ b/tui/src/components/PromptTestModal.tsx @@ -0,0 +1,305 @@ +import React, { useState } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { Form } from "ink-form"; +import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { + Prompt, + GetPromptResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { promptArgsToForm } from "../utils/promptArgsToForm.js"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { ModalBackdrop } from "./ModalBackdrop.js"; + +// Helper to extract error message from various error types +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + return String(error.message); + } + return "Unknown error"; +} + +interface PromptTestModalProps { + prompt: Prompt; + inspectorClient: InspectorClient | null; + width: number; + height: number; + onClose: () => void; +} + +type ModalState = "form" | "loading" | "results"; + +interface PromptResult { + input: Record; + output: GetPromptResult | null; + error?: string; + errorDetails?: unknown; + duration: number; +} + +export function PromptTestModal({ + prompt, + inspectorClient, + width, + height, + onClose, +}: PromptTestModalProps) { + const [state, setState] = useState("form"); + const [result, setResult] = useState(null); + const scrollViewRef = React.useRef(null); + + // Use full terminal dimensions instead of passed dimensions + const [terminalDimensions, setTerminalDimensions] = React.useState({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + + React.useEffect(() => { + const updateDimensions = () => { + setTerminalDimensions({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + }; + process.stdout.on("resize", updateDimensions); + updateDimensions(); + return () => { + process.stdout.off("resize", updateDimensions); + }; + }, [width, height]); + + const formStructure = promptArgsToForm( + prompt.arguments || [], + prompt.name || "Unknown Prompt", + ); + + // Reset state when modal closes + React.useEffect(() => { + return () => { + // Cleanup: reset state when component unmounts + setState("form"); + setResult(null); + }; + }, []); + + // Handle all input when modal is open - prevents input from reaching underlying components + // When in form mode, only handle escape (form handles its own input) + // When in results mode, handle scrolling keys + useInput( + (input: string, key: Key) => { + // Always handle escape to close modal + if (key.escape) { + setState("form"); + setResult(null); + onClose(); + return; + } + + if (state === "form") { + // In form mode, let the form handle all other input + // Don't process anything else - this prevents input from reaching underlying components + return; + } + + if (state === "results") { + // Allow scrolling in results view + if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } + } + }, + { isActive: true }, + ); + + const handleFormSubmit = async (values: Record) => { + if (!inspectorClient || !prompt) return; + + setState("loading"); + const startTime = Date.now(); + + try { + // Get the prompt using the provided arguments + const invocation = await inspectorClient.getPrompt(prompt.name, values); + + const duration = Date.now() - startTime; + + setResult({ + input: values, + output: invocation.result, + duration, + }); + setState("results"); + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = getErrorMessage(error); + + // Extract detailed error information + const errorObj: Record = { + message: errorMessage, + }; + if (error instanceof Error) { + errorObj.name = error.name; + errorObj.stack = error.stack; + } else if (error && typeof error === "object") { + // Try to extract more details from error object + Object.assign(errorObj, error); + } else { + errorObj.error = String(error); + } + + setResult({ + input: values, + output: null, + error: errorMessage, + errorDetails: errorObj, + duration, + }); + setState("results"); + } + }; + + // Calculate modal dimensions - use almost full screen + const modalWidth = terminalDimensions.width - 2; + const modalHeight = terminalDimensions.height - 2; + + return ( + + + {/* Modal Content */} + + {/* Header */} + + + {formStructure.title} + + + (Press ESC to close) + + + {/* Content Area */} + + {state === "form" && ( + + {prompt.description && ( + + {prompt.description} + + )} +
+ handleFormSubmit(values as Record) + } + /> + + )} + + {state === "loading" && ( + + Getting prompt... + + )} + + {state === "results" && result && ( + + + {/* Timing */} + + + Duration: {result.duration}ms + + + + {/* Input */} + {Object.keys(result.input).length > 0 && ( + + + Arguments: + + + + {JSON.stringify(result.input, null, 2)} + + + + )} + + {/* Output or Error */} + {result.error ? ( + + + + Error: + + + + {String(result.error)} + + {result.errorDetails != null ? ( + <> + + + Error Details: + + + + + {JSON.stringify(result.errorDetails, null, 2)} + + + + ) : null} + + ) : ( + + + Prompt Messages: + + + + {JSON.stringify(result.output, null, 2)} + + + + )} + + + )} + + + + ); +} diff --git a/tui/src/components/PromptsTab.tsx b/tui/src/components/PromptsTab.tsx new file mode 100644 index 000000000..52484388b --- /dev/null +++ b/tui/src/components/PromptsTab.tsx @@ -0,0 +1,271 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { + Prompt, + PromptArgument, + GetPromptResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +interface PromptsTabProps { + prompts: Prompt[]; + inspectorClient: InspectorClient | null; + width: number; + height: number; + onCountChange?: (count: number) => void; + focusedPane?: "list" | "details" | null; + onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void; + onFetchPrompt?: (prompt: Prompt) => void; + modalOpen?: boolean; +} + +export function PromptsTab({ + prompts, + inspectorClient, + width, + height, + focusedPane = null, + onViewDetails, + onFetchPrompt, + modalOpen = false, +}: PromptsTabProps) { + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + prompts.length, + visibleCount, + { resetWhen: [prompts] }, + ); + const [error, setError] = useState(null); + const scrollViewRef = useRef(null); + + // Handle arrow key navigation when focused + useInput( + (input: string, key: Key) => { + // Handle Enter key to fetch prompt (works from both list and details) + if (key.return && selectedPrompt && inspectorClient && onFetchPrompt) { + // If prompt has arguments, open modal to collect them + // Otherwise, fetch directly + if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) { + onFetchPrompt(selectedPrompt); + } else { + // No arguments, fetch directly + (async () => { + try { + const invocation = await inspectorClient.getPrompt( + selectedPrompt.name, + ); + // Show result in details modal + if (onViewDetails) { + onViewDetails({ + ...selectedPrompt, + result: invocation.result, + }); + } + } catch (error) { + setError( + error instanceof Error ? error.message : "Failed to get prompt", + ); + } + })(); + } + return; + } + + if (focusedPane === "list") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < prompts.length - 1) { + setSelection(selectedIndex + 1); + } + return; + } + + if (focusedPane === "details") { + // Handle '+' key to view in full screen modal + if (input === "+" && selectedPrompt && onViewDetails) { + onViewDetails(selectedPrompt); + return; + } + + // Scroll the details pane using ink-scroll-view + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { + isActive: + !modalOpen && (focusedPane === "list" || focusedPane === "details"), + }, + ); + + // Reset scroll when selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const selectedPrompt = prompts[selectedIndex] || null; + + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + return ( + + {/* Prompts List */} + + + + Prompts ({prompts.length}) + + + {error ? ( + + {error} + + ) : prompts.length === 0 ? ( + + No prompts available + + ) : ( + + {prompts + .slice(firstVisible, firstVisible + visibleCount) + .map((prompt, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + return ( + + + {isSelected ? "ā–¶ " : " "} + {prompt.name || `Prompt ${index + 1}`} + + + ); + })} + + )} + + + {/* Prompt Details */} + + {selectedPrompt ? ( + <> + {/* Fixed header */} + + + {selectedPrompt.name} + + + + {/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */} + + {/* Description */} + {selectedPrompt.description && ( + <> + {selectedPrompt.description + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + {/* Arguments */} + {selectedPrompt.arguments && + selectedPrompt.arguments.length > 0 && ( + <> + + Arguments: + + {selectedPrompt.arguments.map( + (arg: PromptArgument, idx: number) => ( + + + - {arg.name}:{" "} + {arg.description ?? + (arg as { type?: string }).type ?? + "string"} + + + ), + )} + + )} + + {/* Enter to Get Prompt message */} + + [Enter to Get Prompt] + + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + ↑/↓ to scroll, + to zoom + + + )} + + ) : ( + + Select a prompt to view details + + )} + + + ); +} diff --git a/tui/src/components/RequestsTab.tsx b/tui/src/components/RequestsTab.tsx new file mode 100644 index 000000000..3ddff3010 --- /dev/null +++ b/tui/src/components/RequestsTab.tsx @@ -0,0 +1,365 @@ +import React, { useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { FetchRequestEntry } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +interface RequestsTabProps { + serverName: string | null; + requests: FetchRequestEntry[]; + width: number; + height: number; + onCountChange?: (count: number) => void; + focusedPane?: "requests" | "details" | null; + onViewDetails?: (request: FetchRequestEntry) => void; + modalOpen?: boolean; +} + +export function RequestsTab({ + requests, + width, + height, + onCountChange, + focusedPane = null, + onViewDetails, + modalOpen = false, +}: RequestsTabProps) { + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + requests.length, + visibleCount, + ); + const scrollViewRef = useRef(null); + const selectedRequest = requests[selectedIndex] || null; + + // Handle arrow key navigation and scrolling when focused + useInput( + (input: string, key: Key) => { + if (focusedPane === "requests") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < requests.length - 1) { + setSelection(selectedIndex + 1); + } else if (key.pageUp) { + setSelection(Math.max(0, selectedIndex - visibleCount)); + } else if (key.pageDown) { + setSelection( + Math.min(requests.length - 1, selectedIndex + visibleCount), + ); + } + return; + } + + // details scrolling (only when details pane is focused) + if (focusedPane === "details") { + // Handle '+' key to view in full screen modal + if (input === "+" && selectedRequest && onViewDetails) { + onViewDetails(selectedRequest); + return; + } + + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { isActive: !modalOpen && focusedPane !== undefined }, + ); + + // Update count when requests change + React.useEffect(() => { + onCountChange?.(requests.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requests.length]); + + // Reset details scroll when request selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + const getStatusColor = (status?: number): string => { + if (!status) return "gray"; + if (status >= 200 && status < 300) return "green"; + if (status >= 300 && status < 400) return "yellow"; + if (status >= 400) return "red"; + return "gray"; + }; + + return ( + + {/* Left column - Requests list */} + + + + Requests ({requests.length}) + + + + {/* Requests list */} + {requests.length === 0 ? ( + + No requests + + ) : ( + + {requests + .slice(firstVisible, firstVisible + visibleCount) + .map((req, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + const statusColor = getStatusColor(req.responseStatus); + const statusText = req.responseStatus + ? `${req.responseStatus}` + : req.error + ? "ERROR" + : "..."; + + const categoryLabel = req.category === "auth" ? "AUTH" : "MCP "; + const methodPadded = req.method === "GET" ? "GET " : req.method; + return ( + + + {isSelected ? "ā–¶ " : " "} + {categoryLabel}{" "} + {methodPadded}{" "} + {statusText} + {req.duration !== undefined && ( + {req.duration}ms + )} + + + ); + })} + + )} + + + {/* Right column - Request details */} + + {selectedRequest ? ( + <> + {/* Fixed header */} + + + {selectedRequest.method} {selectedRequest.url} + + + + {/* Scrollable content area */} + + {/* Category */} + + + Category:{" "} + + {selectedRequest.category === "auth" ? "auth" : "transport"} + + + + + {/* Status */} + {selectedRequest.responseStatus !== undefined ? ( + + + Status:{" "} + + {selectedRequest.responseStatus}{" "} + {selectedRequest.responseStatusText || ""} + + + + ) : selectedRequest.error ? ( + + + Error: {selectedRequest.error} + + + ) : ( + + + Request in progress... + + + )} + + {/* Duration */} + {selectedRequest.duration !== undefined && ( + + + {selectedRequest.timestamp.toLocaleTimeString()} ( + {selectedRequest.duration}ms) + + + )} + + {/* Request Headers */} + + Request Headers: + + {Object.entries(selectedRequest.requestHeaders).map( + ([key, value]) => ( + + + {key}: {value} + + + ), + )} + + {/* Request Body */} + {selectedRequest.requestBody && ( + <> + + Request Body: + + {(() => { + try { + const parsed = JSON.parse(selectedRequest.requestBody); + return JSON.stringify(parsed, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + )); + } catch { + return ( + + {selectedRequest.requestBody} + + ); + } + })()} + + )} + + {/* Response Headers */} + {selectedRequest.responseHeaders && + Object.keys(selectedRequest.responseHeaders).length > 0 && ( + <> + + Response Headers: + + {Object.entries(selectedRequest.responseHeaders).map( + ([key, value]) => ( + + + {key}: {value} + + + ), + )} + + )} + + {/* Response Body */} + {selectedRequest.responseBody && ( + <> + + Response Body: + + {(() => { + try { + const parsed = JSON.parse(selectedRequest.responseBody); + return JSON.stringify(parsed, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + )); + } catch { + return ( + + {selectedRequest.responseBody} + + ); + } + })()} + + )} + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + ↑/↓ to scroll, + to zoom + + + )} + + ) : ( + + Select a request to view details + + )} + + + ); +} diff --git a/tui/src/components/ResourceTestModal.tsx b/tui/src/components/ResourceTestModal.tsx new file mode 100644 index 000000000..d1376e53e --- /dev/null +++ b/tui/src/components/ResourceTestModal.tsx @@ -0,0 +1,325 @@ +import React, { useState } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { Form } from "ink-form"; +import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { ModalBackdrop } from "./ModalBackdrop.js"; + +// Helper to extract error message from various error types +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + return String(error.message); + } + return "Unknown error"; +} + +interface ResourceTestModalProps { + template: { + name: string; + uriTemplate: string; + description?: string; + }; + inspectorClient: InspectorClient | null; + width: number; + height: number; + onClose: () => void; +} + +type ModalState = "form" | "loading" | "results"; + +interface ResourceResult { + input: Record; + output: ReadResourceResult | null; + error?: string; + errorDetails?: unknown; + duration: number; + uri: string; +} + +export function ResourceTestModal({ + template, + inspectorClient, + width, + height, + onClose, +}: ResourceTestModalProps) { + const [state, setState] = useState("form"); + const [result, setResult] = useState(null); + const scrollViewRef = React.useRef(null); + + // Use full terminal dimensions instead of passed dimensions + const [terminalDimensions, setTerminalDimensions] = React.useState({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + + React.useEffect(() => { + const updateDimensions = () => { + setTerminalDimensions({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + }; + process.stdout.on("resize", updateDimensions); + updateDimensions(); + return () => { + process.stdout.off("resize", updateDimensions); + }; + }, [width, height]); + + const formStructure = uriTemplateToForm( + template.uriTemplate, + template.name || "Unknown Template", + ); + + // Reset state when modal closes + React.useEffect(() => { + return () => { + // Cleanup: reset state when component unmounts + setState("form"); + setResult(null); + }; + }, []); + + // Handle all input when modal is open - prevents input from reaching underlying components + // When in form mode, only handle escape (form handles its own input) + // When in results mode, handle scrolling keys + useInput( + (input: string, key: Key) => { + // Always handle escape to close modal + if (key.escape) { + setState("form"); + setResult(null); + onClose(); + return; + } + + if (state === "form") { + // In form mode, let the form handle all other input + // Don't process anything else - this prevents input from reaching underlying components + return; + } + + if (state === "results") { + // Allow scrolling in results view + if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } + } + }, + { isActive: true }, + ); + + const handleFormSubmit = async (values: Record) => { + if (!inspectorClient || !template) return; + + setState("loading"); + const startTime = Date.now(); + + try { + // Use InspectorClient's readResourceFromTemplate method which encapsulates template expansion and resource reading + const invocation = await inspectorClient.readResourceFromTemplate( + template.uriTemplate, + values, + ); + + const duration = Date.now() - startTime; + + setResult({ + input: values, + output: invocation.result, // Extract the SDK result from the invocation + duration, + uri: invocation.expandedUri, // Use expandedUri instead of uri + }); + setState("results"); + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = getErrorMessage(error); + + // Try to get expanded URI from error if available, otherwise use template + let uri = template.uriTemplate; + // If the error response contains uri, use it + if (error && typeof error === "object" && "uri" in error) { + uri = (error as { uri: string }).uri; + } + + // Extract detailed error information + const errorObj: Record = { + message: errorMessage, + }; + if (error instanceof Error) { + errorObj.name = error.name; + errorObj.stack = error.stack; + } else if (error && typeof error === "object") { + // Try to extract more details from error object + Object.assign(errorObj, error); + } else { + errorObj.error = String(error); + } + + setResult({ + input: values, + output: null, + error: errorMessage, + errorDetails: errorObj, + duration, + uri, + }); + setState("results"); + } + }; + + // Calculate modal dimensions - use almost full screen + const modalWidth = terminalDimensions.width - 2; + const modalHeight = terminalDimensions.height - 2; + + return ( + + + {/* Modal Content */} + + {/* Header */} + + + {formStructure.title} + + + (Press ESC to close) + + + {/* Content Area */} + + {state === "form" && ( + + {template.description && ( + + {template.description} + + )} + + handleFormSubmit(values as Record) + } + /> + + )} + + {state === "loading" && ( + + Reading resource... + + )} + + {state === "results" && result && ( + + + {/* Timing */} + + + Duration: {result.duration}ms + + + + {/* URI */} + + + URI:{" "} + + {result.uri} + + + {/* Input */} + + + Template Values: + + + + {JSON.stringify(result.input, null, 2)} + + + + + {/* Output or Error */} + {result.error ? ( + + + + Error: + + + + {String(result.error)} + + {result.errorDetails != null ? ( + <> + + + Error Details: + + + + + {JSON.stringify(result.errorDetails, null, 2)} + + + + ) : null} + + ) : ( + + + Resource Content: + + + + {JSON.stringify(result.output, null, 2)} + + + + )} + + + )} + + + + ); +} diff --git a/tui/src/components/ResourcesTab.tsx b/tui/src/components/ResourcesTab.tsx new file mode 100644 index 000000000..bfd198eb1 --- /dev/null +++ b/tui/src/components/ResourcesTab.tsx @@ -0,0 +1,420 @@ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { + Resource, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +interface ResourceTemplate { + name: string; + uriTemplate: string; + description?: string; +} + +interface ResourcesTabProps { + resources: Resource[]; + resourceTemplates?: ResourceTemplate[]; + inspectorClient: InspectorClient | null; + width: number; + height: number; + onCountChange?: (count: number) => void; + focusedPane?: "list" | "details" | null; + onViewDetails?: ( + resource: Resource | { content: ReadResourceResult }, + ) => void; + onFetchResource?: (resource: Resource) => void; + onFetchTemplate?: (template: ResourceTemplate) => void; + modalOpen?: boolean; +} + +export function ResourcesTab({ + resources, + resourceTemplates = [], + inspectorClient, + width, + height, + onCountChange, + focusedPane = null, + onViewDetails, + onFetchResource, + onFetchTemplate, + modalOpen = false, +}: ResourcesTabProps) { + const [error, setError] = useState(null); + const [resourceContent, setResourceContent] = + useState(null); + const [loading, setLoading] = useState(false); + const [shouldFetchResource, setShouldFetchResource] = useState( + null, + ); + const scrollViewRef = useRef(null); + + // Combined list: resources first, then templates - memoized to prevent unnecessary recalculations + const allItems = useMemo( + () => [ + ...resources.map((r) => ({ type: "resource" as const, data: r })), + ...resourceTemplates.map((t) => ({ type: "template" as const, data: t })), + ], + [resources, resourceTemplates], + ); + const totalCount = useMemo( + () => resources.length + resourceTemplates.length, + [resources.length, resourceTemplates.length], + ); + + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + totalCount, + visibleCount, + { resetWhen: [resources] }, + ); + const selectedItem = useMemo( + () => allItems[selectedIndex] || null, + [allItems, selectedIndex], + ); + + // Handle arrow key navigation when focused + useInput( + (input: string, key: Key) => { + // Handle Enter key to fetch resource (works from both list and details) + if ( + key.return && + selectedItem && + inspectorClient && + (onFetchResource || onFetchTemplate) + ) { + if (selectedItem.type === "resource" && selectedItem.data.uri) { + // Trigger fetch for regular resource + setShouldFetchResource(selectedItem.data.uri); + if (onFetchResource) { + onFetchResource(selectedItem.data); + } + } else if (selectedItem.type === "template" && onFetchTemplate) { + // Open modal for template + onFetchTemplate(selectedItem.data); + } + return; + } + + if (focusedPane === "list") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < totalCount - 1) { + setSelection(selectedIndex + 1); + } + return; + } + + if (focusedPane === "details") { + // Handle '+' key to view in full screen modal + if (input === "+" && resourceContent && onViewDetails) { + onViewDetails({ content: resourceContent }); + return; + } + + // Scroll the details pane using ink-scroll-view + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { + isActive: + !modalOpen && (focusedPane === "list" || focusedPane === "details"), + }, + ); + + // Reset scroll when selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + // Clear fetched content when resources change + const prevResourcesRef = useRef(resources); + useEffect(() => { + if (prevResourcesRef.current !== resources) { + setResourceContent(null); + setShouldFetchResource(null); + prevResourcesRef.current = resources; + } + }, [resources]); + + const isResource = selectedItem?.type === "resource"; + const isTemplate = selectedItem?.type === "template"; + const selectedResource = isResource ? selectedItem.data : null; + const selectedTemplate = isTemplate ? selectedItem.data : null; + + // Fetch resource content when shouldFetchResource is set + useEffect(() => { + if (!shouldFetchResource || !inspectorClient) return; + + const fetchContent = async () => { + setLoading(true); + setError(null); + try { + const invocation = + await inspectorClient.readResource(shouldFetchResource); + setResourceContent(invocation.result); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to read resource", + ); + setResourceContent(null); + } finally { + setLoading(false); + setShouldFetchResource(null); + } + }; + + fetchContent(); + }, [shouldFetchResource, inspectorClient]); + + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + // Update count when items change - use ref to track previous count and only call when it actually changes + const prevCountRef = useRef(totalCount); + useEffect(() => { + if (prevCountRef.current !== totalCount) { + prevCountRef.current = totalCount; + onCountChange?.(totalCount); + } + }, [totalCount, onCountChange]); + + return ( + + {/* Resources and Templates List */} + + + + Resources ({totalCount}) + + + {error ? ( + + {error} + + ) : totalCount === 0 ? ( + + No resources available + + ) : ( + + {allItems + .slice(firstVisible, firstVisible + visibleCount) + .map((item, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + const label = + item.type === "resource" + ? item.data.name || item.data.uri || `Resource ${index + 1}` + : item.data.name || + `Template ${index - resources.length + 1}`; + const key = + item.type === "resource" + ? item.data.uri || index + : item.data.uriTemplate || index; + return ( + + + {isSelected ? "ā–¶ " : " "} + {label} + + + ); + })} + + )} + + + {/* Resource Details */} + + {selectedResource ? ( + <> + {/* Fixed header */} + + + {selectedResource.name || selectedResource.uri} + + + + {/* Scrollable content area */} + + {/* Description */} + {selectedResource.description && ( + <> + {selectedResource.description + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + {/* URI */} + {selectedResource.uri && ( + + URI: {selectedResource.uri} + + )} + + {/* MIME Type */} + {selectedResource.mimeType && ( + + MIME Type: {selectedResource.mimeType} + + )} + + {/* Resource Content */} + {loading && ( + + Loading resource content... + + )} + + {!loading && resourceContent && ( + <> + + Content: + + + + {JSON.stringify(resourceContent, null, 2)} + + + + )} + + {!loading && !resourceContent && selectedResource.uri && ( + + [Enter to Fetch Resource] + + )} + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + {resourceContent + ? "↑/↓ to scroll, + to zoom" + : "Enter to fetch, ↑/↓ to scroll"} + + + )} + + ) : selectedTemplate ? ( + <> + {/* Fixed header */} + + + {selectedTemplate.name} + + + + {/* Scrollable content area */} + + {/* Description */} + {selectedTemplate.description && ( + <> + {selectedTemplate.description + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + {/* URI Template */} + {selectedTemplate.uriTemplate && ( + + + URI Template: {selectedTemplate.uriTemplate} + + + )} + + + [Enter to Fetch Resource] + + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + Enter to fetch + + + )} + + ) : ( + + Select a resource or template to view details + + )} + + + ); +} diff --git a/tui/src/components/SelectableItem.tsx b/tui/src/components/SelectableItem.tsx new file mode 100644 index 000000000..a87315a34 --- /dev/null +++ b/tui/src/components/SelectableItem.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Box, Text } from "ink"; + +/** Renders a selectable item: ā–¶ + space when selected, space + space when not. Fixed width to prevent layout shift. */ +export function SelectableItem({ + isSelected, + bold, + children, +}: { + isSelected: boolean; + bold?: boolean; + children: React.ReactNode; +}) { + return ( + + + {isSelected ? "ā–¶ " : " "} + + {children} + + ); +} diff --git a/tui/src/components/Tabs.tsx b/tui/src/components/Tabs.tsx new file mode 100644 index 000000000..59e972166 --- /dev/null +++ b/tui/src/components/Tabs.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { type TabType, tabs } from "./tabsConfig.js"; + +interface TabsProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; + width: number; + counts?: { + info?: number; + auth?: number; + resources?: number; + prompts?: number; + tools?: number; + messages?: number; + requests?: number; + logging?: number; + }; + focused?: boolean; + showAuth?: boolean; + showLogging?: boolean; + showRequests?: boolean; +} + +export function Tabs({ + activeTab, + width, + counts = {}, + focused = false, + showAuth = true, + showLogging = true, + showRequests = false, +}: TabsProps) { + let visibleTabs = tabs; + if (!showAuth) { + visibleTabs = visibleTabs.filter((tab) => tab.id !== "auth"); + } + if (!showLogging) { + visibleTabs = visibleTabs.filter((tab) => tab.id !== "logging"); + } + if (!showRequests) { + visibleTabs = visibleTabs.filter((tab) => tab.id !== "requests"); + } + + return ( + + {visibleTabs.map((tab) => { + const isActive = activeTab === tab.id; + const count = counts[tab.id]; + const countText = count !== undefined ? ` (${count})` : ""; + const firstChar = tab.label[0]; + const restOfLabel = tab.label.slice(1); + + return ( + + + {isActive ? "ā–¶ " : " "} + {firstChar} + {restOfLabel} + {countText} + + + ); + })} + + ); +} diff --git a/tui/src/components/ToolTestModal.tsx b/tui/src/components/ToolTestModal.tsx new file mode 100644 index 000000000..7bff33cea --- /dev/null +++ b/tui/src/components/ToolTestModal.tsx @@ -0,0 +1,291 @@ +import React, { useState } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { Form } from "ink-form"; +import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { JsonValue } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { schemaToForm } from "../utils/schemaToForm.js"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { ModalBackdrop } from "./ModalBackdrop.js"; + +interface ToolTestModalProps { + tool: Tool; + inspectorClient: InspectorClient | null; + width: number; + height: number; + onClose: () => void; +} + +type ModalState = "form" | "loading" | "results"; + +interface ToolResult { + input: Record; + output: CallToolResult | null; + error?: string; + errorDetails?: unknown; + duration: number; +} + +export function ToolTestModal({ + tool, + inspectorClient, + width, + height, + onClose, +}: ToolTestModalProps) { + const [state, setState] = useState("form"); + const [result, setResult] = useState(null); + const scrollViewRef = React.useRef(null); + + // Use full terminal dimensions instead of passed dimensions + const [terminalDimensions, setTerminalDimensions] = React.useState({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + + React.useEffect(() => { + const updateDimensions = () => { + setTerminalDimensions({ + width: process.stdout.columns || width, + height: process.stdout.rows || height, + }); + }; + process.stdout.on("resize", updateDimensions); + updateDimensions(); + return () => { + process.stdout.off("resize", updateDimensions); + }; + }, [width, height]); + + const formStructure = tool?.inputSchema + ? schemaToForm(tool.inputSchema, tool.name || "Unknown Tool") + : { + title: `Test Tool: ${tool?.name || "Unknown"}`, + sections: [{ title: "Parameters", fields: [] }], + }; + + // Reset state when modal closes + React.useEffect(() => { + return () => { + // Cleanup: reset state when component unmounts + setState("form"); + setResult(null); + }; + }, []); + + // Handle all input when modal is open - prevents input from reaching underlying components + // When in form mode, only handle escape (form handles its own input) + // When in results mode, handle scrolling keys + useInput( + (input: string, key: Key) => { + // Always handle escape to close modal + if (key.escape) { + setState("form"); + setResult(null); + onClose(); + return; + } + + if (state === "form") { + // In form mode, let the form handle all other input + // Don't process anything else - this prevents input from reaching underlying components + return; + } + + if (state === "results") { + // Allow scrolling in results view + if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } + } + }, + { isActive: true }, + ); + + const handleFormSubmit = async (values: Record) => { + if (!inspectorClient || !tool) return; + + setState("loading"); + const startTime = Date.now(); + + try { + // Use InspectorClient.callTool() which handles parameter conversion and metadata + const invocation = await inspectorClient.callTool(tool, values); + + const duration = Date.now() - startTime; + + // InspectorClient.callTool() returns ToolCallInvocation + // Check if the call succeeded and extract the result + if (!invocation.success || invocation.result === null) { + // Error case: tool call failed + setResult({ + input: values, + output: null, + error: invocation.error || "Tool call failed", + errorDetails: invocation, + duration, + }); + } else { + // Success case: extract the result + const result = invocation.result; + // Check for error indicators in the result (SDK may return error in result) + const isError = "isError" in result && result.isError === true; + + setResult({ + input: values, + output: isError ? null : result, + error: isError ? "Tool returned an error" : undefined, + errorDetails: isError ? result : undefined, + duration, + }); + } + setState("results"); + } catch (error) { + const duration = Date.now() - startTime; + const errorObj = + error instanceof Error + ? { message: error.message, name: error.name, stack: error.stack } + : { error: String(error) }; + + setResult({ + input: values, + output: null, + error: error instanceof Error ? error.message : "Unknown error", + errorDetails: errorObj, + duration, + }); + setState("results"); + } + }; + + // Calculate modal dimensions - use almost full screen + const modalWidth = terminalDimensions.width - 2; + const modalHeight = terminalDimensions.height - 2; + + return ( + + + {/* Modal Content */} + + {/* Header */} + + + {formStructure.title} + + + (Press ESC to close) + + + {/* Content Area */} + + {state === "form" && ( + + + void handleFormSubmit(value as Record) + } + /> + + )} + + {state === "loading" && ( + + Calling tool... + + )} + + {state === "results" && result && ( + + + {/* Timing */} + + + Duration: {result.duration}ms + + + + {/* Input */} + + + Input: + + + + {JSON.stringify(result.input, null, 2)} + + + + + {/* Output or Error */} + {result.error ? ( + + + Error: + + + {String(result.error)} + + {result.errorDetails != null ? ( + <> + + + Error Details: + + + + + {JSON.stringify(result.errorDetails, null, 2)} + + + + ) : null} + + ) : ( + + + Output: + + + + {JSON.stringify(result.output, null, 2)} + + + + )} + + + )} + + + + ); +} diff --git a/tui/src/components/ToolsTab.tsx b/tui/src/components/ToolsTab.tsx new file mode 100644 index 000000000..238da14d5 --- /dev/null +++ b/tui/src/components/ToolsTab.tsx @@ -0,0 +1,242 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Box, Text, useInput, type Key } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { useSelectableList } from "../hooks/useSelectableList.js"; + +interface ToolsTabProps { + tools: Tool[]; + isConnected: boolean; + width: number; + height: number; + onCountChange?: (count: number) => void; + focusedPane?: "list" | "details" | null; + onTestTool?: (tool: Tool) => void; + onViewDetails?: (tool: Tool) => void; + modalOpen?: boolean; +} + +export function ToolsTab({ + tools, + isConnected, + width, + height, + focusedPane = null, + onTestTool, + onViewDetails, + modalOpen = false, +}: ToolsTabProps) { + const visibleCount = Math.max(1, height - 7); + const { selectedIndex, firstVisible, setSelection } = useSelectableList( + tools.length, + visibleCount, + { resetWhen: [tools] }, + ); + const [error] = useState(null); + const scrollViewRef = useRef(null); + const listWidth = Math.floor(width * 0.4); + const detailWidth = width - listWidth; + + // Handle arrow key navigation when focused + useInput( + (input: string, key: Key) => { + // Handle Enter key to test tool (works from both list and details) + if (key.return && selectedTool && isConnected && onTestTool) { + onTestTool(selectedTool); + return; + } + + if (focusedPane === "list") { + if (key.upArrow && selectedIndex > 0) { + setSelection(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < tools.length - 1) { + setSelection(selectedIndex + 1); + } + return; + } + + if (focusedPane === "details") { + // Handle '+' key to view in full screen modal + if (input === "+" && selectedTool && onViewDetails) { + onViewDetails(selectedTool); + return; + } + + // Scroll the details pane using ink-scroll-view + if (key.upArrow) { + scrollViewRef.current?.scrollBy(-1); + } else if (key.downArrow) { + scrollViewRef.current?.scrollBy(1); + } else if (key.pageUp) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(-viewportHeight); + } else if (key.pageDown) { + const viewportHeight = + scrollViewRef.current?.getViewportHeight() || 1; + scrollViewRef.current?.scrollBy(viewportHeight); + } + } + }, + { + isActive: + !modalOpen && (focusedPane === "list" || focusedPane === "details"), + }, + ); + + // Reset scroll when selection changes + useEffect(() => { + scrollViewRef.current?.scrollTo(0); + }, [selectedIndex]); + + const selectedTool = tools[selectedIndex] || null; + + return ( + + {/* Tools List */} + + + + Tools ({tools.length}) + + + {error ? ( + + {error} + + ) : tools.length === 0 ? ( + + No tools available + + ) : ( + + {tools + .slice(firstVisible, firstVisible + visibleCount) + .map((tool, i) => { + const index = firstVisible + i; + const isSelected = index === selectedIndex; + return ( + + + {isSelected ? "ā–¶ " : " "} + {tool.name || `Tool ${index + 1}`} + + + ); + })} + + )} + + + {/* Tool Details */} + + {selectedTool ? ( + <> + {/* Fixed header */} + + + {selectedTool.name} + + {isConnected && ( + + + [Enter to Test] + + + )} + + + {/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */} + + {/* Description */} + {selectedTool.description && ( + <> + {selectedTool.description + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + {/* Input Schema */} + {selectedTool.inputSchema && ( + <> + + Input Schema: + + {JSON.stringify(selectedTool.inputSchema, null, 2) + .split("\n") + .map((line: string, idx: number) => ( + + {line} + + ))} + + )} + + + {/* Fixed footer - only show when details pane is focused */} + {focusedPane === "details" && ( + + + ↑/↓ to scroll, + to zoom + + + )} + + ) : ( + + Select a tool to view details + + )} + + + ); +} diff --git a/tui/src/components/tabsConfig.ts b/tui/src/components/tabsConfig.ts new file mode 100644 index 000000000..1aae81095 --- /dev/null +++ b/tui/src/components/tabsConfig.ts @@ -0,0 +1,20 @@ +export type TabType = + | "info" + | "auth" + | "resources" + | "prompts" + | "tools" + | "messages" + | "requests" + | "logging"; + +export const tabs: { id: TabType; label: string; accelerator: string }[] = [ + { id: "info", label: "Info", accelerator: "i" }, + { id: "auth", label: "Auth", accelerator: "a" }, + { id: "resources", label: "Resources", accelerator: "r" }, + { id: "prompts", label: "Prompts", accelerator: "p" }, + { id: "tools", label: "Tools", accelerator: "t" }, + { id: "messages", label: "Messages", accelerator: "m" }, + { id: "requests", label: "HTTP Requests", accelerator: "h" }, + { id: "logging", label: "Logging", accelerator: "l" }, +]; diff --git a/tui/src/hooks/useSelectableList.ts b/tui/src/hooks/useSelectableList.ts new file mode 100644 index 000000000..cef926c10 --- /dev/null +++ b/tui/src/hooks/useSelectableList.ts @@ -0,0 +1,61 @@ +import { useState, useEffect, useCallback } from "react"; + +function clampFirstVisible( + first: number, + selected: number, + visibleCount: number, +): number { + if (selected < first) return selected; + if (selected >= first + visibleCount) return selected - visibleCount + 1; + return first; +} + +export interface UseSelectableListOptions { + /** When these change, reset selection to 0 (e.g. [tools] when switching servers) */ + resetWhen?: unknown[]; +} + +/** + * Manages selection and scroll position for a virtualized list. + * Returns selection state and a setSelection that updates both + * selectedIndex and firstVisible so the selected item stays in view. + */ +export function useSelectableList( + itemCount: number, + visibleCount: number, + options?: UseSelectableListOptions, +) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [firstVisible, setFirstVisible] = useState(0); + + const setSelection = useCallback( + (newIndex: number) => { + setSelectedIndex(newIndex); + setFirstVisible((prev) => + clampFirstVisible(prev, newIndex, visibleCount), + ); + }, + [visibleCount], + ); + + // Reset when deps change (e.g. different server) + useEffect(() => { + if (options?.resetWhen) { + setSelectedIndex(0); + setFirstVisible(0); + } + }, [options?.resetWhen]); + + // Clamp when list shrinks + useEffect(() => { + if (itemCount > 0 && selectedIndex >= itemCount) { + const newIndex = itemCount - 1; + setSelectedIndex(newIndex); + setFirstVisible((prev) => + clampFirstVisible(prev, newIndex, visibleCount), + ); + } + }, [itemCount, selectedIndex, visibleCount]); + + return { selectedIndex, firstVisible, setSelection }; +} diff --git a/tui/src/logger.ts b/tui/src/logger.ts new file mode 100644 index 000000000..fe64650f6 --- /dev/null +++ b/tui/src/logger.ts @@ -0,0 +1,23 @@ +import path from "node:path"; +import pino from "pino"; + +const logDir = + process.env.MCP_INSPECTOR_LOG_DIR ?? + path.join( + process.env.HOME || process.env.USERPROFILE || ".", + ".mcp-inspector", + ); +const logPath = path.join(logDir, "auth.log"); + +/** + * TUI file logger for auth and InspectorClient events. + * Writes to ~/.mcp-inspector/auth.log so TUI console output is not corrupted. + * The app controls logger creation and configuration. + */ +export const tuiLogger = pino( + { + name: "mcp-inspector-tui", + level: process.env.LOG_LEVEL ?? "info", + }, + pino.destination({ dest: logPath, append: true, mkdir: true }), +); diff --git a/tui/src/utils/openUrl.ts b/tui/src/utils/openUrl.ts new file mode 100644 index 000000000..a2a7d9639 --- /dev/null +++ b/tui/src/utils/openUrl.ts @@ -0,0 +1,12 @@ +import open from "open"; + +/** + * Opens a URL in the user's default browser. + * Used when handling oauthAuthorizationRequired to launch the OAuth authorization page. + * + * @param url - URL to open (string or URL) + * @returns Promise that resolves when the opener completes (or rejects on error) + */ +export async function openUrl(url: string | URL): Promise { + await open(typeof url === "string" ? url : url.href); +} diff --git a/tui/src/utils/promptArgsToForm.ts b/tui/src/utils/promptArgsToForm.ts new file mode 100644 index 000000000..01da3d3e2 --- /dev/null +++ b/tui/src/utils/promptArgsToForm.ts @@ -0,0 +1,47 @@ +/** + * Converts prompt arguments to ink-form format + */ + +import type { FormStructure, FormSection, FormField } from "ink-form"; +import type { PromptArgument } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Converts prompt arguments array to ink-form structure + */ +export function promptArgsToForm( + promptArguments: PromptArgument[], + promptName: string, +): FormStructure { + const fields: FormField[] = []; + + if (!promptArguments || promptArguments.length === 0) { + return { + title: `Get Prompt: ${promptName}`, + sections: [{ title: "Parameters", fields: [] }], + }; + } + + for (const arg of promptArguments) { + const field: FormField = { + name: arg.name, + label: arg.name, + type: "string", // Prompt arguments are always strings + required: arg.required !== false, // Default to required unless explicitly false + description: arg.description, + }; + + fields.push(field); + } + + const sections: FormSection[] = [ + { + title: "Prompt Arguments", + fields, + }, + ]; + + return { + title: `Get Prompt: ${promptName}`, + sections, + }; +} diff --git a/tui/src/utils/schemaToForm.ts b/tui/src/utils/schemaToForm.ts new file mode 100644 index 000000000..c85bbff98 --- /dev/null +++ b/tui/src/utils/schemaToForm.ts @@ -0,0 +1,137 @@ +/** + * Converts JSON Schema to ink-form format + */ + +import type { FormStructure, FormSection, FormField } from "ink-form"; + +/** Minimal JSON Schema property shape used when building tool parameter forms */ +interface JsonSchemaProperty { + type?: string; + title?: string; + enum?: unknown[]; + items?: { enum?: unknown[] }; + minimum?: number; + maximum?: number; + default?: unknown; +} + +/** Minimal JSON Schema object shape (properties + required) */ +interface JsonSchemaObject { + properties?: Record; + required?: string[]; +} + +/** + * Converts a JSON Schema to ink-form structure + */ +export function schemaToForm( + schema: JsonSchemaObject | null | undefined, + toolName: string, +): FormStructure { + const fields: FormField[] = []; + + if (!schema || !schema.properties) { + return { + title: `Test Tool: ${toolName}`, + sections: [{ title: "Parameters", fields: [] }], + }; + } + + const properties = schema.properties || {}; + const required = schema.required || []; + + for (const [key, prop] of Object.entries(properties)) { + const property = prop as JsonSchemaProperty; + const baseField = { + name: key, + label: property.title || key, + required: required.includes(key), + }; + + let field: FormField; + + // Handle enum -> select + if (property.enum) { + if (property.type === "array" && property.items?.enum) { + // For array of enums, we'll use select but handle it differently + // Note: ink-form doesn't have multiselect, so we'll use select + field = { + type: "select", + ...baseField, + options: property.items.enum.map((val: unknown) => ({ + label: String(val), + value: String(val), + })), + } as FormField; + } else { + // Single select + field = { + type: "select", + ...baseField, + options: property.enum.map((val: unknown) => ({ + label: String(val), + value: String(val), + })), + } as FormField; + } + } else { + // Map JSON Schema types to ink-form types + switch (property.type) { + case "string": + field = { + type: "string", + ...baseField, + } as FormField; + break; + case "integer": + field = { + type: "integer", + ...baseField, + ...(property.minimum !== undefined && { min: property.minimum }), + ...(property.maximum !== undefined && { max: property.maximum }), + } as FormField; + break; + case "number": + field = { + type: "float", + ...baseField, + ...(property.minimum !== undefined && { min: property.minimum }), + ...(property.maximum !== undefined && { max: property.maximum }), + } as FormField; + break; + case "boolean": + field = { + type: "boolean", + ...baseField, + } as FormField; + break; + default: + // Default to string for unknown types + field = { + type: "string", + ...baseField, + } as FormField; + } + } + + // Set initial value from default (ink-form FormField allows initialValue for some types) + if (property.default !== undefined) { + (field as FormField & { initialValue?: unknown }).initialValue = + property.default; + } + + fields.push(field); + } + + const sections: FormSection[] = [ + { + title: "Parameters", + fields, + }, + ]; + + return { + title: `Test Tool: ${toolName}`, + sections, + }; +} diff --git a/tui/src/utils/uriTemplateToForm.ts b/tui/src/utils/uriTemplateToForm.ts new file mode 100644 index 000000000..f8d2ee10b --- /dev/null +++ b/tui/src/utils/uriTemplateToForm.ts @@ -0,0 +1,47 @@ +/** + * Converts URI Template to ink-form format for resource templates + */ + +import type { FormStructure, FormSection, FormField } from "ink-form"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; + +/** + * Converts a URI Template to ink-form structure + */ +export function uriTemplateToForm( + uriTemplate: string, + templateName: string, +): FormStructure { + const fields: FormField[] = []; + + try { + const template = new UriTemplate(uriTemplate); + const variableNames = template.variableNames || []; + + for (const variableName of variableNames) { + const field: FormField = { + name: variableName, + label: variableName, + type: "string", + required: false, // URI template variables are typically optional + }; + + fields.push(field); + } + } catch (error) { + // If parsing fails, return empty form + console.error("Failed to parse URI template:", error); + } + + const sections: FormSection[] = [ + { + title: "Template Variables", + fields, + }, + ]; + + return { + title: `Read Resource: ${templateName}`, + sections, + }; +} diff --git a/tui/test-config.json b/tui/test-config.json new file mode 100644 index 000000000..0738f3328 --- /dev/null +++ b/tui/test-config.json @@ -0,0 +1 @@ +{ "servers": [] } diff --git a/server/tsconfig.json b/tui/tsconfig.json similarity index 50% rename from server/tsconfig.json rename to tui/tsconfig.json index b5a92612a..9c5f0e4e3 100644 --- a/server/tsconfig.json +++ b/tui/tsconfig.json @@ -2,15 +2,17 @@ "compilerOptions": { "target": "ES2022", "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./build", - "rootDir": "./src", + "moduleResolution": "node16", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "outDir": "./build", + "rootDir": "." }, - "include": ["src/**/*"], - "exclude": ["node_modules", "packages", "**/*.spec.ts"] + "include": ["src/**/*", "tui.tsx"], + "exclude": ["node_modules", "build"], + "references": [{ "path": "../core" }] } diff --git a/tui/tui.tsx b/tui/tui.tsx new file mode 100755 index 000000000..4c15618f7 --- /dev/null +++ b/tui/tui.tsx @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { render } from "ink"; +import App from "./src/App.js"; + +export async function runTui(args?: string[]): Promise { + const program = new Command(); + + program + .name("mcp-inspector-tui") + .description("Terminal UI for MCP Inspector") + .argument("", "path to MCP servers config file") + .option( + "--client-id ", + "OAuth client ID (static client) for HTTP servers", + ) + .option( + "--client-secret ", + "OAuth client secret (for confidential clients)", + ) + .option( + "--client-metadata-url ", + "OAuth Client ID Metadata Document URL (CIMD) for HTTP servers", + ) + .option( + "--callback-url ", + "OAuth redirect/callback listener URL (default: http://127.0.0.1:0/oauth/callback)", + ) + .parse(args ?? process.argv); + + const configFile = program.args[0]; + const options = program.opts() as { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrl?: string; + }; + + if (!configFile) { + program.error("Config file is required"); + } + + interface CallbackUrlConfig { + hostname: string; + port: number; + pathname: string; + } + + function parseCallbackUrl(raw?: string): CallbackUrlConfig { + if (!raw) { + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + } + let url: URL; + try { + url = new URL(raw); + } catch (err) { + program.error( + `Invalid callback URL: ${(err as Error)?.message ?? String(err)}`, + ); + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + } + if (url.protocol !== "http:") { + program.error("Callback URL must use http scheme"); + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + } + const hostname = url.hostname; + if (!hostname) { + program.error("Callback URL must include a hostname"); + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + } + const pathname = url.pathname || "/"; + let port: number; + if (url.port === "") { + port = 80; + } else { + port = Number(url.port); + if ( + !Number.isFinite(port) || + !Number.isInteger(port) || + port < 0 || + port > 65535 + ) { + program.error("Callback URL port must be between 0 and 65535"); + } + } + return { hostname, port, pathname }; + } + + const callbackUrlConfig = parseCallbackUrl(options.callbackUrl); + + // Intercept stdout.write to filter out \x1b[3J (Erase Saved Lines) + // This prevents Ink's clearTerminal from clearing scrollback on macOS Terminal + // We can't access Ink's internal instance to prevent clearTerminal from being called, + // so we filter the escape code instead. + // Pattern is built with String.fromCharCode(0x1b) instead of a regex literal containing + // \x1b so that the source has no control character (satisfies no-control-regex). + const ansiEraseSavedLines = new RegExp( + String.fromCharCode(0x1b) + "\\[3J", + "g", + ); + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = function ( + chunk: string | Buffer, + encoding?: BufferEncoding | ((err?: Error) => void), + cb?: (err?: Error) => void, + ): boolean { + if (typeof chunk === "string") { + if (chunk.includes("\x1b[3J")) { + chunk = chunk.replace(ansiEraseSavedLines, ""); + } + } else if (Buffer.isBuffer(chunk)) { + if (chunk.includes("\x1b[3J")) { + let str = chunk.toString("utf8"); + str = str.replace(ansiEraseSavedLines, ""); + chunk = Buffer.from(str, "utf8"); + } + } + if (typeof encoding === "function") { + return ( + originalWrite as ( + chunk: string | Buffer, + cb?: (err?: Error) => void, + ) => boolean + )(chunk, encoding); + } + return ( + originalWrite as ( + chunk: string | Buffer, + encoding?: BufferEncoding, + cb?: (err?: Error) => void, + ) => boolean + )(chunk, encoding, cb); + }; + + // Enter alternate screen buffer before rendering + if (process.stdout.isTTY) { + process.stdout.write("\x1b[?1049h"); + } + + // Render the app + const instance = render( + , + ); + + // Wait for exit, then switch back from alternate screen + try { + await instance.waitUntilExit(); + // Unmount has completed - clearTerminal was patched to not include \x1b[3J + // Switch back from alternate screen + if (process.stdout.isTTY) { + process.stdout.write("\x1b[?1049l"); + } + process.exit(0); + } catch (error: unknown) { + if (process.stdout.isTTY) { + process.stdout.write("\x1b[?1049l"); + } + console.error("Error:", error); + process.exit(1); + } +} + +runTui(); diff --git a/tui/vitest.config.ts b/tui/vitest.config.ts new file mode 100644 index 000000000..6e2f764ae --- /dev/null +++ b/tui/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["__tests__/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/build/**"], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..a2ee9b00b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +/** + * Root config: run only cli, shared, and web unit tests. + * No root-level test discovery (so e2e, and core/build are not run). + */ +export default defineConfig({ + test: { + projects: [ + "cli/vitest.config.ts", + "core/vitest.config.ts", + "test-servers/vitest.config.ts", + "tui/vitest.config.ts", + "web/vitest.config.ts", + ], + }, +}); diff --git a/client/.gitignore b/web/.gitignore similarity index 100% rename from client/.gitignore rename to web/.gitignore diff --git a/client/README.md b/web/README.md similarity index 100% rename from client/README.md rename to web/README.md diff --git a/web/bin/start.js b/web/bin/start.js new file mode 100755 index 000000000..49e4082dd --- /dev/null +++ b/web/bin/start.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +import open from "open"; +import { resolve, dirname } from "path"; +import { spawnPromise, spawn } from "spawn-rx"; +import { fileURLToPath } from "url"; +import { randomBytes } from "crypto"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function startDevClient(clientOptions) { + const { + CLIENT_PORT, + inspectorApiToken, + dangerouslyOmitAuth, + command, + mcpServerArgs, + transport, + serverUrl, + headers, + envVars, + cwd, + abort, + cancelledRef, + } = clientOptions; + const clientCommand = "npx"; + const host = process.env.HOST || "localhost"; + const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; + + // Env for the child process (Vite): API token, initial MCP config, and client config vars + const configEnv = { + ...process.env, + CLIENT_PORT, + ...(dangerouslyOmitAuth + ? {} + : { [API_SERVER_ENV_VARS.AUTH_TOKEN]: inspectorApiToken }), + ...(command ? { MCP_INITIAL_COMMAND: command } : {}), + ...(mcpServerArgs && mcpServerArgs.length > 0 + ? { MCP_INITIAL_ARGS: mcpServerArgs.join(" ") } + : {}), + ...(transport ? { MCP_INITIAL_TRANSPORT: transport } : {}), + ...(serverUrl ? { MCP_INITIAL_SERVER_URL: serverUrl } : {}), + ...(headers && Object.keys(headers).length > 0 + ? { MCP_INITIAL_HEADERS: JSON.stringify(headers) } + : {}), + ...(envVars && Object.keys(envVars).length > 0 + ? { MCP_ENV_VARS: JSON.stringify(envVars) } + : {}), + ...(cwd ? { MCP_INITIAL_CWD: cwd } : {}), + }; + + const client = spawn(clientCommand, clientArgs, { + cwd: resolve(__dirname, ".."), + env: configEnv, + signal: abort.signal, + echoOutput: true, + }); + + // Include Inspector API auth token in URL for client (omit when auth disabled) + const params = new URLSearchParams(); + if (!dangerouslyOmitAuth && inspectorApiToken) { + params.set(API_SERVER_ENV_VARS.AUTH_TOKEN, inspectorApiToken); + } + const url = + params.size > 0 + ? `http://${host}:${CLIENT_PORT}/?${params.toString()}` + : `http://${host}:${CLIENT_PORT}`; + + // Give vite time to start before opening or logging the URL + setTimeout(() => { + console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); + console.log( + ` Static files served by: Vite (dev) / Inspector API server (prod)\n`, + ); + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log("🌐 Opening browser..."); + open(url); + } + }, 3000); + + await new Promise((resolve) => { + client.subscribe({ + complete: resolve, + error: (err) => { + if (!cancelledRef.current || process.env.DEBUG) { + console.error("Client error:", err); + } + resolve(null); + }, + next: () => {}, // We're using echoOutput + }); + }); +} + +async function startProdClient(clientOptions) { + const { + CLIENT_PORT, + inspectorApiToken, + dangerouslyOmitAuth, + abort, + command, + mcpServerArgs, + transport, + serverUrl, + headers, + envVars, + cwd, + } = clientOptions; + const honoServerPath = resolve(__dirname, "../dist/server.js"); + + // Inspector API server (Hono) serves static files + /api/*; it logs and opens browser when listening + try { + await spawnPromise("node", [honoServerPath], { + env: { + ...process.env, + CLIENT_PORT, + ...(dangerouslyOmitAuth + ? {} + : { [API_SERVER_ENV_VARS.AUTH_TOKEN]: inspectorApiToken }), + ...(command ? { MCP_INITIAL_COMMAND: command } : {}), + ...(mcpServerArgs && mcpServerArgs.length > 0 + ? { MCP_INITIAL_ARGS: mcpServerArgs.join(" ") } + : {}), + ...(transport ? { MCP_INITIAL_TRANSPORT: transport } : {}), + ...(serverUrl ? { MCP_INITIAL_SERVER_URL: serverUrl } : {}), + ...(headers && Object.keys(headers).length > 0 + ? { MCP_INITIAL_HEADERS: JSON.stringify(headers) } + : {}), + ...(envVars && Object.keys(envVars).length > 0 + ? { MCP_ENV_VARS: JSON.stringify(envVars) } + : {}), + ...(cwd ? { MCP_INITIAL_CWD: cwd } : {}), + }, + signal: abort.signal, + echoOutput: true, + }); + } catch (err) { + // Child already printed the message (e.g. PORT IS IN USE); exit cleanly without stack + const code = err?.code ?? err?.exitCode; + if (typeof code === "number" && code !== 0) { + process.exit(code); + } + throw err; + } +} + +async function main() { + // Parse command line arguments + const args = process.argv.slice(2); + const envVars = {}; + const mcpServerArgs = []; + let command = null; + let parsingFlags = true; + let isDev = false; + let transport = null; + let serverUrl = null; + let headers = null; + let cwd = null; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (parsingFlags && arg === "--") { + parsingFlags = false; + continue; + } + + if (!parsingFlags) { + if (command === null) command = arg; + else mcpServerArgs.push(arg); + continue; + } + + if (arg === "--dev") { + isDev = true; + continue; + } + + if (arg === "--transport" && i + 1 < args.length) { + transport = args[++i]; + continue; + } + + if (arg === "--server-url" && i + 1 < args.length) { + serverUrl = args[++i]; + continue; + } + + if (arg === "--headers" && i + 1 < args.length) { + try { + headers = JSON.parse(args[++i]); + } catch { + // Ignore invalid JSON + } + continue; + } + + if (arg === "--cwd" && i + 1 < args.length) { + cwd = args[++i]; + continue; + } + + if (arg === "-e" && i + 1 < args.length) { + const envVar = args[++i]; + const equalsIndex = envVar.indexOf("="); + + if (equalsIndex !== -1) { + const key = envVar.substring(0, equalsIndex); + const value = envVar.substring(equalsIndex + 1); + envVars[key] = value; + } else { + envVars[envVar] = ""; + } + } else if (!command) { + command = arg; + } else { + mcpServerArgs.push(arg); + } + } + + // Env fallback when no command/args were passed on the command line (explicit args take precedence) + if (!command && process.env.MCP_INITIAL_COMMAND) { + command = process.env.MCP_INITIAL_COMMAND; + const initialArgs = process.env.MCP_INITIAL_ARGS; + if (initialArgs) + mcpServerArgs.push(...initialArgs.split(" ").filter(Boolean)); + } + if (!serverUrl && process.env.MCP_INITIAL_SERVER_URL) { + serverUrl = process.env.MCP_INITIAL_SERVER_URL; + } + if (!transport && process.env.MCP_INITIAL_TRANSPORT) { + transport = process.env.MCP_INITIAL_TRANSPORT; + } + if (!headers && process.env.MCP_INITIAL_HEADERS) { + try { + headers = JSON.parse(process.env.MCP_INITIAL_HEADERS); + } catch { + // Ignore invalid JSON + } + } + if (!cwd && process.env.MCP_INITIAL_CWD) { + cwd = process.env.MCP_INITIAL_CWD; + } + // For stdio (when command is set), default cwd to process.cwd() if not provided + if (!cwd && command) { + cwd = process.cwd(); + } + + const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; + + console.log( + isDev + ? "Starting MCP inspector in development mode..." + : "Starting MCP inspector...", + ); + + const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; + + // Generate Inspector API auth token when auth is enabled (honor legacy MCP_PROXY_AUTH_TOKEN if present) + const inspectorApiToken = dangerouslyOmitAuth + ? "" + : process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || + process.env[LEGACY_AUTH_TOKEN_ENV] || + randomBytes(32).toString("hex"); + + const abort = new AbortController(); + + const cancelledRef = { current: false }; + process.on("SIGINT", () => { + cancelledRef.current = true; + abort.abort(); + }); + + if (isDev) { + // In dev mode: start Vite with Inspector API middleware + try { + const clientOptions = { + CLIENT_PORT, + inspectorApiToken, + dangerouslyOmitAuth, + command, + mcpServerArgs, + transport, + serverUrl, + headers, + envVars, + cwd, + abort, + cancelledRef, + }; + await startDevClient(clientOptions); + } catch (e) { + if (!cancelledRef.current || process.env.DEBUG) throw e; + } + } else { + // In prod mode: start Inspector API server (serves static files + /api/* endpoints) + try { + const clientOptions = { + CLIENT_PORT, + inspectorApiToken, + dangerouslyOmitAuth, + command, + mcpServerArgs, + transport, + serverUrl, + headers, + envVars, + cwd, + abort, + cancelledRef, + }; + await startProdClient(clientOptions); + } catch (e) { + if (!cancelledRef.current || process.env.DEBUG) throw e; + } + } + + return 0; +} + +main() + .then((_) => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/client/components.json b/web/components.json similarity index 100% rename from client/components.json rename to web/components.json diff --git a/client/e2e/cli-arguments.spec.ts b/web/e2e/cli-arguments.spec.ts similarity index 100% rename from client/e2e/cli-arguments.spec.ts rename to web/e2e/cli-arguments.spec.ts diff --git a/client/e2e/global-teardown.js b/web/e2e/global-teardown.js similarity index 100% rename from client/e2e/global-teardown.js rename to web/e2e/global-teardown.js diff --git a/client/e2e/startup-state.spec.ts b/web/e2e/startup-state.spec.ts similarity index 100% rename from client/e2e/startup-state.spec.ts rename to web/e2e/startup-state.spec.ts diff --git a/client/e2e/transport-type-dropdown.spec.ts b/web/e2e/transport-type-dropdown.spec.ts similarity index 100% rename from client/e2e/transport-type-dropdown.spec.ts rename to web/e2e/transport-type-dropdown.spec.ts diff --git a/client/index.html b/web/index.html similarity index 90% rename from client/index.html rename to web/index.html index b3736a822..57756a2a2 100644 --- a/client/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - MCP Inspector + MCP Inspector Web
diff --git a/client/package.json b/web/package.json similarity index 72% rename from client/package.json rename to web/package.json index 4586168fe..d44a4f0c3 100644 --- a/client/package.json +++ b/web/package.json @@ -1,14 +1,14 @@ { - "name": "@modelcontextprotocol/inspector-client", + "name": "@modelcontextprotocol/inspector-web", "version": "0.20.0", - "description": "Client-side application for the Model Context Protocol inspector", - "license": "SEE LICENSE IN LICENSE", - "author": "Model Context Protocol a Series of LF Projects, LLC.", + "description": "Web application for the Model Context Protocol inspector", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "type": "module", "bin": { - "mcp-inspector-client": "./bin/start.js" + "mcp-inspector-web": "./bin/start.js" }, "files": [ "bin", @@ -16,17 +16,21 @@ ], "scripts": { "dev": "vite --port 6274", - "build": "tsc -b && vite build", - "lint": "eslint .", + "typecheck": "tsc -p tsconfig.app.json", + "build": "npm run typecheck && vite build && tsc -p tsconfig.server.json", + "start": "node dist/server.js", + "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "preview": "vite preview --port 6274", - "test": "jest --config jest.config.cjs", - "test:watch": "jest --config jest.config.cjs --watch", + "test": "vitest run", + "test:watch": "vitest", "test:e2e": "playwright test e2e && npm run cleanup:e2e", "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { "@mcp-ui/client": "^6.0.0", "@modelcontextprotocol/ext-apps": "^1.0.0", + "@hono/node-server": "^1.19.0", + "@modelcontextprotocol/inspector-core": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", @@ -49,20 +53,19 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-simple-code-editor": "^0.14.1", - "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", - "zod": "^3.25.76" + "zod": "^3.25.76", + "open": "^10.1.0", + "pino": "^9.6.0" }, "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", - "@types/serve-handler": "^6.1.4", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "^10.4.20", "co": "^4.6.0", @@ -70,15 +73,13 @@ "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", + "jsdom": "^25.0.1", "postcss": "^8.5.6", "tailwindcss": "^3.4.13", "tailwindcss-animate": "^1.0.7", - "ts-jest": "^29.4.0", "typescript": "^5.5.3", "typescript-eslint": "^8.38.0", - "vite": "^7.1.11" + "vite": "^7.1.11", + "vitest": "^4.0.17" } } diff --git a/client/playwright.config.ts b/web/playwright.config.ts similarity index 91% rename from client/playwright.config.ts rename to web/playwright.config.ts index 570dd054e..ef15cb046 100644 --- a/client/playwright.config.ts +++ b/web/playwright.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ command: "npm run dev", url: "http://localhost:6274", reuseExistingServer: !process.env.CI, + // So /api/config returns 200 and the main UI (Sidebar with Transport Type) is shown instead of the token screen + env: { ...process.env, DANGEROUSLY_OMIT_AUTH: "1" }, }, testDir: "./e2e", diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 000000000..bdf07fb6c --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,11 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + tailwindcss: { config: path.join(__dirname, "tailwind.config.js") }, + autoprefixer: {}, + }, +}; diff --git a/client/public/mcp.svg b/web/public/mcp.svg similarity index 100% rename from client/public/mcp.svg rename to web/public/mcp.svg diff --git a/client/src/App.css b/web/src/App.css similarity index 100% rename from client/src/App.css rename to web/src/App.css diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..94a9c13e6 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,2193 @@ +import { + CompatibilityCallToolResult, + CreateMessageResult, + Resource, + ResourceReference, + PromptReference, + Root, + ServerNotification, + Tool, + type Task, + LoggingLevel, +} from "@modelcontextprotocol/sdk/types.js"; +import { + hasValidMetaName, + hasValidMetaPrefix, + isReservedMetaKey, +} from "@/utils/metaUtils"; +import { cacheToolOutputSchemas } from "./utils/schemaUtils"; +import { cleanParams } from "./utils/paramUtils"; +import type { JsonSchemaType } from "./utils/jsonUtils"; +import type { JsonValue } from "@modelcontextprotocol/inspector-core/json/jsonUtils.js"; +import React, { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; +import { usePagedTools } from "@modelcontextprotocol/inspector-core/react/usePagedTools.js"; +import { usePagedResources } from "@modelcontextprotocol/inspector-core/react/usePagedResources.js"; +import { usePagedResourceTemplates } from "@modelcontextprotocol/inspector-core/react/usePagedResourceTemplates.js"; +import { usePagedPrompts } from "@modelcontextprotocol/inspector-core/react/usePagedPrompts.js"; +import { usePagedRequestorTasks } from "@modelcontextprotocol/inspector-core/react/usePagedRequestorTasks.js"; +import { useMessageLog } from "@modelcontextprotocol/inspector-core/react/useMessageLog.js"; +import { useFetchRequestLog } from "@modelcontextprotocol/inspector-core/react/useFetchRequestLog.js"; +import { useStderrLog } from "@modelcontextprotocol/inspector-core/react/useStderrLog.js"; +import { + PagedToolsState, + PagedResourcesState, + PagedResourceTemplatesState, + PagedPromptsState, + PagedRequestorTasksState, + MessageLogState, + FetchRequestLogState, + StderrLogState, +} from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; +import { + InspectorClient, + type InspectorClientOptions, + type MessageEntry, +} from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { + createWebEnvironment, + type WebEnvironmentResult, +} from "./lib/adapters/environmentFactory"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote/index.js"; +import { RemoteInspectorClientStorage } from "@modelcontextprotocol/inspector-core/mcp/remote/index.js"; +import { parseOAuthState } from "@modelcontextprotocol/inspector-core/auth/index.js"; +import { webConfigToMcpServerConfig } from "./lib/adapters/configAdapter"; +import { useToast } from "./lib/hooks/useToast"; +import { + useDraggablePane, + useDraggableSidebar, +} from "./lib/hooks/useDraggablePane"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { + AppWindow, + Bell, + Files, + FolderTree, + Hammer, + Hash, + Key, + ListTodo, + MessageSquare, + Network, + Settings, + Terminal, +} from "lucide-react"; + +import "./App.css"; +import AuthDebugger from "./components/AuthDebugger"; +import ConsoleTab from "./components/ConsoleTab"; +import HistoryAndNotifications from "./components/HistoryAndNotifications"; +import PingTab from "./components/PingTab"; +import PromptsTab, { Prompt } from "./components/PromptsTab"; +import RequestsTab from "./components/RequestsTab"; +import ResourcesTab from "./components/ResourcesTab"; +import RootsTab from "./components/RootsTab"; +import SamplingTab, { PendingRequest } from "./components/SamplingTab"; +import Sidebar from "./components/Sidebar"; +import ToolsTab from "./components/ToolsTab"; +import TasksTab from "./components/TasksTab"; +import AppsTab from "./components/AppsTab"; +import { InspectorConfig } from "./lib/configurationTypes"; +import { + getInitialSseUrl, + getInitialTransportType, + getInitialCommand, + getInitialArgs, + getInspectorApiToken, + getMCPTaskTtl, + initializeInspectorConfig, + removeAuthTokenFromUrl, + saveInspectorConfig, +} from "./utils/configUtils"; +import ElicitationTab, { + PendingElicitationRequest, + ElicitationResponse, +} from "./components/ElicitationTab"; +import { + CustomHeaders, + migrateFromLegacyAuth, + recordToHeaders, +} from "./lib/types/customHeaders"; +import MetadataTab from "./components/MetadataTab"; +import TokenLoginScreen from "./components/TokenLoginScreen"; + +const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; + +const filterReservedMetadata = ( + metadata: Record, +): Record => { + return Object.entries(metadata).reduce>( + (acc, [key, value]) => { + if ( + !isReservedMetaKey(key) && + hasValidMetaPrefix(key) && + hasValidMetaName(key) + ) { + acc[key] = value; + } + return acc; + }, + {}, + ); +}; + +// Shared predicates for history/notifications views; used for both getMessages (display) and clearMessages (clear) +const notificationsMessagePredicate = (m: MessageEntry) => + m.direction === "notification"; +const historyMessagePredicate = (m: MessageEntry) => m.direction === "request"; + +const App = () => { + const [resourceContent, setResourceContent] = useState(""); + const [resourceContentMap, setResourceContentMap] = useState< + Record + >({}); + const [promptContent, setPromptContent] = useState(""); + const [toolResult, setToolResult] = + useState(null); + const [errors, setErrors] = useState>({ + resources: null, + prompts: null, + tools: null, + tasks: null, + }); + const [command, setCommand] = useState(getInitialCommand); + const [args, setArgs] = useState(getInitialArgs); + + const [sseUrl, setSseUrl] = useState(getInitialSseUrl); + const [sandboxUrl, setSandboxUrl] = useState(undefined); + const [transportType, setTransportType] = useState< + "stdio" | "sse" | "streamable-http" + >(getInitialTransportType); + const [logLevel, setLogLevel] = useState("debug"); + const [notifications, setNotifications] = useState([]); + const [roots, setRoots] = useState([]); + const [env, setEnv] = useState>({}); + const [cwd, setCwd] = useState(""); + + const [isPollingTask, setIsPollingTask] = useState(false); + const [config, setConfig] = useState(() => + initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY), + ); + // Config fetch: always fetch on load; 200 → main app, 401 → token screen; retry on token submit + const [configFetchStatus, setConfigFetchStatus] = useState< + "loading" | "ok" | "need_token" + >("loading"); + const [configFetchError, setConfigFetchError] = useState(null); + const [configFetchTrigger, setConfigFetchTrigger] = useState(0); + const [authAcceptedWithoutToken, setAuthAcceptedWithoutToken] = + useState(false); + const [bearerToken, setBearerToken] = useState(() => { + return localStorage.getItem("lastBearerToken") || ""; + }); + + const [headerName, setHeaderName] = useState(() => { + return localStorage.getItem("lastHeaderName") || ""; + }); + + const [oauthClientId, setOauthClientId] = useState(() => { + return localStorage.getItem("lastOauthClientId") || ""; + }); + + const [oauthScope, setOauthScope] = useState(() => { + return localStorage.getItem("lastOauthScope") || ""; + }); + + const [oauthClientSecret, setOauthClientSecret] = useState(() => { + return localStorage.getItem("lastOauthClientSecret") || ""; + }); + + const [oauthClientMetadataUrl, setOauthClientMetadataUrl] = useState( + () => { + return localStorage.getItem("lastOauthClientMetadataUrl") || ""; + }, + ); + + // Custom headers state with migration from legacy auth + const [customHeaders, setCustomHeaders] = useState(() => { + const savedHeaders = localStorage.getItem("lastCustomHeaders"); + if (savedHeaders) { + try { + return JSON.parse(savedHeaders); + } catch (error) { + console.warn( + `Failed to parse custom headers: "${savedHeaders}", will try legacy migration`, + error, + ); + // Fall back to migration if JSON parsing fails + } + } + + // Migrate from legacy auth if available + const legacyToken = localStorage.getItem("lastBearerToken") || ""; + const legacyHeaderName = localStorage.getItem("lastHeaderName") || ""; + + if (legacyToken) { + return migrateFromLegacyAuth(legacyToken, legacyHeaderName); + } + + // Default to empty array + return [ + { + name: "Authorization", + value: "Bearer ", + enabled: false, + }, + ]; + }); + + const [pendingSampleRequests, setPendingSampleRequests] = useState< + Array< + PendingRequest & { + resolve: (result: CreateMessageResult) => void; + reject: (error: Error) => void; + } + > + >([]); + const [pendingElicitationRequests, setPendingElicitationRequests] = useState< + Array< + PendingElicitationRequest & { + resolve: (response: ElicitationResponse) => void; + decline: (error: Error) => void; + } + > + >([]); + const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); + + // Metadata state - persisted in localStorage + const [metadata, setMetadata] = useState>(() => { + const savedMetadata = localStorage.getItem("lastMetadata"); + if (savedMetadata) { + try { + const parsed = JSON.parse(savedMetadata); + if (parsed && typeof parsed === "object") { + return filterReservedMetadata(parsed); + } + } catch (error) { + console.warn("Failed to parse saved metadata:", error); + } + } + return {}; + }); + + const handleMetadataChange = (newMetadata: Record) => { + const sanitizedMetadata = filterReservedMetadata(newMetadata); + setMetadata(sanitizedMetadata); + localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata)); + }; + const rootsRef = useRef([]); + + const [selectedResource, setSelectedResource] = useState( + null, + ); + const [resourceSubscriptions, setResourceSubscriptions] = useState< + Set + >(new Set()); + + const [selectedPrompt, setSelectedPrompt] = useState(null); + const [selectedTool, setSelectedTool] = useState(null); + const [nextResourceCursor, setNextResourceCursor] = useState< + string | undefined + >(); + const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< + string | undefined + >(); + const [nextPromptCursor, setNextPromptCursor] = useState< + string | undefined + >(); + const [nextToolCursor, setNextToolCursor] = useState(); + const [selectedTask, setSelectedTask] = useState(null); + const progressTokenRef = useRef(0); + + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + const initialTab = hash || "resources"; + return initialTab; + }); + + const currentTabRef = useRef(activeTab); + const lastToolCallOriginTabRef = useRef(activeTab); + + useEffect(() => { + currentTabRef.current = activeTab; + }, [activeTab]); + + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const { + width: sidebarWidth, + isDragging: isSidebarDragging, + handleDragStart: handleSidebarDragStart, + } = useDraggableSidebar(320); + + // InspectorClient is created lazily when needed (connect/auth operations) + const [inspectorClient, setInspectorClient] = + useState(null); + // Paged state for tools, resources, resource templates, prompts; created with client + const [pagedToolsState, setPagedToolsState] = + useState(null); + const [pagedResourcesState, setPagedResourcesState] = + useState(null); + const [pagedResourceTemplatesState, setPagedResourceTemplatesState] = + useState(null); + const [pagedPromptsState, setPagedPromptsState] = + useState(null); + const [pagedRequestorTasksState, setPagedRequestorTasksState] = + useState(null); + // Log state managers (messages, fetch requests, stderr); created with client + const [messageLogState, setMessageLogState] = + useState(null); + const [fetchRequestLogState, setFetchRequestLogState] = + useState(null); + const [stderrLogState, setStderrLogState] = useState( + null, + ); + // Same logger passed to InspectorClient (from createWebEnvironment); exposed for AuthDebugger/OAuthCallback + const [inspectorLogger, setInspectorLogger] = useState< + WebEnvironmentResult["logger"] | null + >(null); + // Track the token used to create the current inspectorClient + const inspectorClientTokenRef = useRef(undefined); + + const { toast } = useToast(); + + // Helper function to ensure InspectorClient exists and is created with current token + // We use a ref to always read the latest config value, avoiding stale closure issues + const configRef = useRef(config); + // Update ref synchronously whenever config changes (before useEffect runs) + configRef.current = config; + + // True only when the last config fetch was triggered by the user submitting the token form (so we only show "Token incorrect." for 401 after submit, not on initial load) + const tokenSubmitCausedLastFetchRef = useRef(false); + + // Ref so the config-fetch callback can apply state to the current mount (avoids Strict Mode unmount dropping updates) + const applyConfigRef = useRef({ + setConfigFetchStatus, + setConfigFetchError, + setAuthAcceptedWithoutToken, + setEnv, + setCommand, + setArgs, + setTransportType, + setSseUrl, + setSandboxUrl, + setCwd, + setCustomHeaders, + }); + applyConfigRef.current = { + setConfigFetchStatus, + setConfigFetchError, + setAuthAcceptedWithoutToken, + setEnv, + setCommand, + setArgs, + setTransportType, + setSseUrl, + setSandboxUrl, + setCwd, + setCustomHeaders, + }; + + // Helper to check if we can create InspectorClient (without actually creating it) + const canCreateInspectorClient = useCallback((): boolean => { + const currentConfig = configRef.current; + const configItem = currentConfig.MCP_INSPECTOR_API_TOKEN; + const tokenValue = configItem?.value; + const tokenString = + typeof tokenValue === "string" ? tokenValue : String(tokenValue || ""); + const currentToken = tokenString.trim() || undefined; + return !!currentToken && (!!command || !!sseUrl); + }, [command, sseUrl]); + + const ensureInspectorClient = useCallback((): InspectorClient | null => { + // Read current token from config ref to ensure we always get the latest value + const currentConfig = configRef.current; + const configItem = currentConfig.MCP_INSPECTOR_API_TOKEN; + const tokenValue = configItem?.value; + + // Handle different value types (string, number, boolean, etc.) + const tokenString = + typeof tokenValue === "string" ? tokenValue : String(tokenValue || ""); + const currentToken = tokenString.trim() || undefined; + + // Allow no token only when server already accepted us without one (e.g. DANGEROUSLY_OMIT_AUTH) + if (!currentToken && !authAcceptedWithoutToken) { + toast({ + title: "API Token Required", + description: "Please set the API Token in Configuration to connect.", + variant: "destructive", + }); + return null; + } + + // Check if server config is set (handle empty strings) + const hasCommand = command && command.trim().length > 0; + const hasSseUrl = sseUrl && sseUrl.trim().length > 0; + + if (!hasCommand && !hasSseUrl) { + toast({ + title: "Server Configuration Required", + description: "Please configure the server command or URL.", + variant: "destructive", + }); + return null; + } + + // If inspectorClient exists, check if token changed + if (inspectorClient && inspectorClientTokenRef.current !== currentToken) { + toast({ + title: "API Token Changed", + description: "API token has changed. Please disconnect and reconnect.", + variant: "destructive", + }); + return null; + } + + // If inspectorClient exists and token matches, return it + if (inspectorClient && inspectorClientTokenRef.current === currentToken) { + return inspectorClient; + } + + // Extract sessionId from OAuth callback if present + let sessionId: string | undefined; + const urlParams = new URLSearchParams(window.location.search); + const stateParam = urlParams.get("state"); + if (stateParam) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + sessionId = parsedState.authId; + } + } + + // Create new InspectorClient + try { + const mcpConfig = webConfigToMcpServerConfig( + transportType, + command, + args, + sseUrl, + env, + customHeaders, + cwd, + ); + + const redirectUrlProvider = { + getRedirectUrl: () => `${window.location.origin}/oauth/callback`, + }; + + const { environment, logger } = createWebEnvironment( + currentToken, + redirectUrlProvider, + ); + setInspectorLogger(logger !== undefined ? logger : null); + + // Create session storage for persisting state across OAuth redirects + const baseUrl = `${window.location.protocol}//${window.location.host}`; + const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args); + const sessionStorage = new RemoteInspectorClientStorage({ + baseUrl, + authToken: currentToken, + fetchFn, + }); + + // Only include oauth config if at least one OAuth field is provided + // This prevents InspectorClient from initializing OAuth when not needed + const hasOAuthConfig = + oauthClientId || + oauthClientSecret || + oauthScope || + oauthClientMetadataUrl; + + const clientOptions: InspectorClientOptions = { + environment, + sessionId, + receiverTasks: true, + receiverTaskTtlMs: getMCPTaskTtl(currentConfig), + elicit: { form: true, url: true }, + }; + + if (hasOAuthConfig) { + clientOptions.oauth = { + clientId: oauthClientId || undefined, + clientSecret: oauthClientSecret || undefined, + clientMetadataUrl: oauthClientMetadataUrl || undefined, + scope: oauthScope || undefined, + }; + } + + const client = new InspectorClient(mcpConfig, clientOptions); + inspectorClientTokenRef.current = currentToken; + messageLogState?.destroy(); + fetchRequestLogState?.destroy(); + stderrLogState?.destroy(); + pagedResourcesState?.destroy(); + pagedResourceTemplatesState?.destroy(); + pagedPromptsState?.destroy(); + pagedRequestorTasksState?.destroy(); + setInspectorClient(client); + setPagedToolsState(new PagedToolsState(client)); + setPagedResourcesState(new PagedResourcesState(client)); + setPagedResourceTemplatesState(new PagedResourceTemplatesState(client)); + setPagedPromptsState(new PagedPromptsState(client)); + setPagedRequestorTasksState(new PagedRequestorTasksState(client)); + setMessageLogState(new MessageLogState(client)); + setFetchRequestLogState( + new FetchRequestLogState(client, { sessionStorage, sessionId }), + ); + setStderrLogState(new StderrLogState(client)); + return client; + } catch (error) { + toast({ + title: "Failed to Create Client", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + return null; + } + }, [ + command, + sseUrl, + transportType, + args, + env, + customHeaders, + cwd, + oauthClientId, + oauthClientSecret, + oauthClientMetadataUrl, + oauthScope, + inspectorClient, + messageLogState, + fetchRequestLogState, + stderrLogState, + pagedResourcesState, + pagedResourceTemplatesState, + pagedPromptsState, + pagedRequestorTasksState, + toast, + authAcceptedWithoutToken, + ]); + + // Use InspectorClient hook + const { + status: connectionStatus, + capabilities: serverCapabilities, + serverInfo: serverImplementation, + appRendererClient, + disconnect: disconnectMcpServer, + } = useInspectorClient(inspectorClient); + + // Log state from managers + const { messages: inspectorMessages } = useMessageLog(messageLogState); + const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); + const { stderrLogs } = useStderrLog(stderrLogState); + + // Tools from PagedToolsState (paged load, clear) + const { + tools: pagedTools, + loadPage: loadToolsPage, + clear: clearPagedTools, + } = usePagedTools(inspectorClient, pagedToolsState); + + // Resources, resource templates, prompts from paged state managers + const { + resources: pagedResources, + loadPage: loadResourcesPage, + clear: clearPagedResources, + } = usePagedResources(inspectorClient, pagedResourcesState); + const { + resourceTemplates: pagedResourceTemplates, + loadPage: loadResourceTemplatesPage, + clear: clearPagedResourceTemplates, + } = usePagedResourceTemplates(inspectorClient, pagedResourceTemplatesState); + const { + prompts: pagedPrompts, + loadPage: loadPromptsPage, + clear: clearPagedPrompts, + } = usePagedPrompts(inspectorClient, pagedPromptsState); + const { + tasks: pagedRequestorTasks, + loadPage: loadTasksPage, + clear: clearPagedTasks, + nextCursor: nextTaskCursor, + } = usePagedRequestorTasks(inspectorClient, pagedRequestorTasksState); + + // Server supports task-augmented tools/call per SDK: capabilities.tasks.requests.tools.call + const serverSupportsTaskToolCalls = useMemo( + () => !!serverCapabilities?.tasks?.requests?.tools?.call, + [serverCapabilities?.tasks?.requests?.tools?.call], + ); + + // Wrap connect to ensure InspectorClient exists first; show toast on error + const connectMcpServer = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client) return; // Error already shown in ensureInspectorClient + + try { + await client.connect(); + } catch (error) { + const status = (error as { status?: number }).status; + if (status === 401) { + try { + await client.authenticate(); + } catch (authError) { + setIsAuthDebuggerVisible(true); + toast({ + title: "Authentication required", + description: + authError instanceof Error + ? authError.message + : String(authError), + variant: "destructive", + }); + } + } else { + const message = error instanceof Error ? error.message : String(error); + toast({ + title: "Connection failed", + description: message, + variant: "destructive", + }); + } + if (client.getStatus() === "connecting") { + await client.disconnect(); + } + } + }, [ensureInspectorClient, toast]); + + // Use ref to track previous serialized value to prevent infinite loops + const previousNotificationsRef = useRef("[]"); + + // Sync notifications from message log (same predicate as clearMessages for this view) + useEffect(() => { + if (!messageLogState) return; + const extracted = messageLogState + .getMessages(notificationsMessagePredicate) + .map((msg) => msg.message as ServerNotification); + const serialized = JSON.stringify(extracted); + if (serialized !== previousNotificationsRef.current) { + setNotifications(extracted); + previousNotificationsRef.current = serialized; + } + }, [messageLogState, inspectorMessages]); + + // Set up event listeners for sampling and elicitation + useEffect(() => { + if (!inspectorClient) return; + + // Handle sampling requests + const handleNewPendingSample = (event: CustomEvent) => { + const sample = event.detail; + const numericId = getNumericId(sample.id); + const originatingTab = lastToolCallOriginTabRef.current; + setPendingSampleRequests((prev) => [ + ...prev, + { + id: numericId, + request: sample.request, + originatingTab, + resolve: async (result: CreateMessageResult) => { + await sample.respond(result); + }, + reject: async (error: Error) => { + await sample.reject(error); + }, + }, + ]); + setActiveTab("sampling"); + window.location.hash = "sampling"; + }; + + // Handle elicitation requests + const handleNewPendingElicitation = (event: CustomEvent) => { + const elicitation = event.detail; + const currentTab = lastToolCallOriginTabRef.current; + const numericId = getNumericId(elicitation.id); + const params = elicitation.request.params ?? {}; + const isUrl = params.mode === "url"; + + const baseItem = { + id: numericId, + elicitationId: elicitation.id as string, + originatingTab: currentTab, + resolve: async (result: ElicitationResponse) => { + await elicitation.respond(result); + }, + decline: async (error: Error) => { + elicitation.reject(error); + elicitation.remove(); + console.error("Elicitation request rejected:", error); + }, + }; + + if (isUrl) { + setPendingElicitationRequests((prev) => [ + ...prev, + { + ...baseItem, + request: { + mode: "url", + id: numericId, + message: params.message as string, + url: params.url as string, + elicitationId: params.elicitationId as string, + }, + }, + ]); + } else { + setPendingElicitationRequests((prev) => [ + ...prev, + { + ...baseItem, + request: { + mode: "form", + id: numericId, + message: params.message as string, + requestedSchema: params.requestedSchema, + }, + }, + ]); + } + + setActiveTab("elicitations"); + window.location.hash = "elicitations"; + }; + + const handlePendingElicitationsChange = () => { + const stillPending = inspectorClient.getPendingElicitations(); + const pendingIds = new Set(stillPending.map((e) => e.id)); + setPendingElicitationRequests((prev) => + prev.filter((r) => pendingIds.has(r.elicitationId)), + ); + }; + + inspectorClient.addEventListener( + "newPendingSample", + handleNewPendingSample, + ); + inspectorClient.addEventListener( + "newPendingElicitation", + handleNewPendingElicitation, + ); + inspectorClient.addEventListener( + "pendingElicitationsChange", + handlePendingElicitationsChange, + ); + + return () => { + inspectorClient.removeEventListener( + "newPendingSample", + handleNewPendingSample, + ); + inspectorClient.removeEventListener( + "newPendingElicitation", + handleNewPendingElicitation, + ); + inspectorClient.removeEventListener( + "pendingElicitationsChange", + handlePendingElicitationsChange, + ); + }; + }, [inspectorClient]); + + // Expose InspectorClient to window for debugging + useEffect(() => { + const win = window as Window & { + __inspectorClient?: typeof inspectorClient; + }; + if (!inspectorClient) { + if (win.__inspectorClient) { + delete win.__inspectorClient; + } + return; + } + + win.__inspectorClient = inspectorClient; + }, [inspectorClient]); + + const handleCompletion = useCallback( + async ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + context?: Record, + _signal?: AbortSignal, + ): Promise => { + if (!inspectorClient) return []; + const result = await inspectorClient.getCompletions( + ref.type === "ref/resource" + ? { type: "ref/resource", uri: ref.uri } + : { type: "ref/prompt", name: ref.name }, + argName, + value, + context, + undefined, // metadata + ); + return result.values || []; + }, + [inspectorClient], + ); + + const completionsSupported = + serverCapabilities?.completions !== undefined && + serverCapabilities.completions !== null; + + // Request history (same predicate as clearMessages for this view) + const requestHistory = + messageLogState?.getMessages(historyMessagePredicate).map((msg) => ({ + request: JSON.stringify(msg.message), + response: msg.response ? JSON.stringify(msg.response) : undefined, + })) ?? []; + + const clearRequestHistory = useCallback(() => { + if (messageLogState) { + messageLogState.clearMessages(historyMessagePredicate); + } + }, [messageLogState]); + + useEffect(() => { + if (serverCapabilities) { + const hash = window.location.hash.slice(1); + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tools ? ["apps"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "console", + "auth", + ]; + + const isValidTab = validTabs.includes(hash); + + if (!isValidTab) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + + setActiveTab(defaultTab); + window.location.hash = defaultTab; + } + } + }, [serverCapabilities]); + + useEffect(() => { + localStorage.setItem("lastCommand", command); + }, [command]); + + useEffect(() => { + localStorage.setItem("lastArgs", args); + }, [args]); + + useEffect(() => { + localStorage.setItem("lastSseUrl", sseUrl); + }, [sseUrl]); + + useEffect(() => { + localStorage.setItem("lastTransportType", transportType); + }, [transportType]); + + useEffect(() => { + if (bearerToken) { + localStorage.setItem("lastBearerToken", bearerToken); + } else { + localStorage.removeItem("lastBearerToken"); + } + }, [bearerToken]); + + useEffect(() => { + if (headerName) { + localStorage.setItem("lastHeaderName", headerName); + } else { + localStorage.removeItem("lastHeaderName"); + } + }, [headerName]); + + useEffect(() => { + localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); + }, [customHeaders]); + + // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists + useEffect(() => { + if (customHeaders.length === 0 && (bearerToken || headerName)) { + const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName); + if (migratedHeaders.length > 0) { + setCustomHeaders(migratedHeaders); + // Clear legacy auth after migration + setBearerToken(""); + setHeaderName(""); + } + } + }, [bearerToken, headerName, customHeaders, setCustomHeaders]); + + useEffect(() => { + localStorage.setItem("lastOauthClientId", oauthClientId); + }, [oauthClientId]); + + useEffect(() => { + localStorage.setItem("lastOauthScope", oauthScope); + }, [oauthScope]); + + useEffect(() => { + localStorage.setItem("lastOauthClientSecret", oauthClientSecret); + }, [oauthClientSecret]); + + useEffect(() => { + localStorage.setItem("lastOauthClientMetadataUrl", oauthClientMetadataUrl); + }, [oauthClientMetadataUrl]); + + useEffect(() => { + saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); + }, [config]); + + // Persist immediately when config changes from Sidebar so new tabs (e.g. OAuth callback) have it + const setConfigAndPersist = useCallback((newConfig: InspectorConfig) => { + setConfig(newConfig); + saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, newConfig); + }, []); + + const onOAuthConnect = useCallback(() => { + setIsAuthDebuggerVisible(false); + void connectMcpServer(); + }, [connectMcpServer]); + + const handleTokenSubmit = useCallback( + (token: string) => { + setConfigFetchError(null); + tokenSubmitCausedLastFetchRef.current = true; + setConfig((prev) => ({ + ...prev, + MCP_INSPECTOR_API_TOKEN: { + ...prev.MCP_INSPECTOR_API_TOKEN, + value: token, + }, + })); + // Persist immediately so refresh/callback keeps the token + saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, { + ...config, + MCP_INSPECTOR_API_TOKEN: { + ...config.MCP_INSPECTOR_API_TOKEN, + value: token, + }, + }); + setConfigFetchStatus("loading"); + setConfigFetchTrigger((k) => k + 1); + }, + [config], + ); + + // Fetch /api/config once on load and when user submits token (retry). Token from config or URL. + const doFetchConfig = useCallback(() => { + const params = new URLSearchParams(window.location.search); + const tokenFromUrl = + params.get(API_SERVER_ENV_VARS.AUTH_TOKEN) ?? + params.get(LEGACY_AUTH_TOKEN_ENV) ?? + undefined; + const token = getInspectorApiToken(config) ?? tokenFromUrl; + + const url = new URL("/api/config", window.location.origin); + const headers: Record = {}; + if (token) headers["x-mcp-remote-auth"] = `Bearer ${token}`; + + fetch(url.toString(), { headers }) + .then((res) => { + const apply = applyConfigRef.current; + if (res.status === 401) { + const showIncorrect = + !!token && tokenSubmitCausedLastFetchRef.current; + tokenSubmitCausedLastFetchRef.current = false; + apply.setConfigFetchError(showIncorrect ? "Token incorrect." : null); + apply.setConfigFetchStatus("need_token"); + return null; + } + if (!res.ok) { + tokenSubmitCausedLastFetchRef.current = false; + apply.setConfigFetchError(null); + apply.setConfigFetchStatus("need_token"); + return null; + } + return res.json(); + }) + .then((data: Record | null) => { + if (!data) return; + const apply = applyConfigRef.current; + tokenSubmitCausedLastFetchRef.current = false; + apply.setConfigFetchError(null); + apply.setConfigFetchStatus("ok"); + if (!token) apply.setAuthAcceptedWithoutToken(true); + if ( + data.defaultEnvironment && + typeof data.defaultEnvironment === "object" + ) { + apply.setEnv(data.defaultEnvironment as Record); + } + // Transport config: either all from URL params or all from config, never mixed + const urlParams = new URLSearchParams(window.location.search); + const hasTransportUrlParams = [ + "transport", + "serverUrl", + "serverCommand", + "serverArgs", + ].some((p) => urlParams.has(p)); + if (!hasTransportUrlParams) { + apply.setCommand((data.defaultCommand as string) ?? ""); + const argsVal = data.defaultArgs; + apply.setArgs( + Array.isArray(argsVal) + ? argsVal.join(" ") + : typeof argsVal === "string" + ? argsVal + : "", + ); + const transport = data.defaultTransport as + | "stdio" + | "sse" + | "streamable-http" + | undefined; + apply.setTransportType(transport || "stdio"); + apply.setSseUrl((data.defaultServerUrl as string) ?? ""); + apply.setCwd((data.defaultCwd as string) ?? ""); + if ( + data.defaultHeaders && + typeof data.defaultHeaders === "object" && + !Array.isArray(data.defaultHeaders) + ) { + apply.setCustomHeaders( + recordToHeaders(data.defaultHeaders as Record), + ); + } + } + apply.setSandboxUrl( + typeof data.sandboxUrl === "string" ? data.sandboxUrl : undefined, + ); + }) + .catch(() => { + tokenSubmitCausedLastFetchRef.current = false; + applyConfigRef.current.setConfigFetchError(null); + applyConfigRef.current.setConfigFetchStatus("need_token"); + }); + }, [config]); + + useEffect(() => { + doFetchConfig(); + }, [configFetchTrigger, doFetchConfig]); + + // Remove API token from URL after it has been read into config (keeps address bar clean) + useEffect(() => { + removeAuthTokenFromUrl(); + }, []); + + // Sync roots with InspectorClient + // Only run when inspectorClient changes, not when roots changes (to avoid infinite loop) + // The rootsChange event listener handles updates after initial sync + useEffect(() => { + if (!inspectorClient) return; + + // Get initial roots from InspectorClient + const inspectorRoots = inspectorClient.getRoots(); + setRoots(inspectorRoots); + rootsRef.current = inspectorRoots; + }, [inspectorClient]); + + // Listen for roots changes from InspectorClient + useEffect(() => { + if (!inspectorClient) return; + + const handleRootsChange = (event: CustomEvent) => { + setRoots(event.detail); + rootsRef.current = event.detail; + }; + + inspectorClient.addEventListener("rootsChange", handleRootsChange); + return () => { + inspectorClient.removeEventListener("rootsChange", handleRootsChange); + }; + }, [inspectorClient]); + + useEffect(() => { + if (connectionStatus === "connected" && !window.location.hash) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + window.location.hash = defaultTab; + } else if (connectionStatus !== "connected" && window.location.hash) { + // Clear hash when disconnected - completely remove the fragment + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + }, [connectionStatus, serverCapabilities]); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash && hash !== activeTab) { + setActiveTab(hash); + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, [activeTab]); + + // When transport is stdio, Requests tab is hidden; switch away if it was selected + useEffect(() => { + if ( + connectionStatus === "connected" && + transportType === "stdio" && + activeTab === "requests" + ) { + setActiveTab("ping"); + window.location.hash = "ping"; + } + }, [connectionStatus, transportType, activeTab]); + + // Map string IDs from InspectorClient to numbers for component compatibility + const stringIdToNumber = useRef>(new Map()); + const nextNumericId = useRef(1); + + const getNumericId = (stringId: string): number => { + if (!stringIdToNumber.current.has(stringId)) { + stringIdToNumber.current.set(stringId, nextNumericId.current++); + } + return stringIdToNumber.current.get(stringId)!; + }; + + const validTabsForNavigation = useMemo( + () => [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + ...(serverCapabilities?.tasks ? ["tasks"] : []), + ...(serverCapabilities?.tools ? ["apps"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ], + [serverCapabilities], + ); + + const navigateToTab = useCallback( + (tab: string) => { + if (validTabsForNavigation.includes(tab)) { + setActiveTab(tab); + window.location.hash = tab; + setTimeout(() => { + setActiveTab(tab); + window.location.hash = tab; + }, 100); + } + }, + [validTabsForNavigation], + ); + + const handleApproveSampling = (id: number, result: CreateMessageResult) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.resolve(result); + if (request?.originatingTab) { + navigateToTab(request.originatingTab); + } + return prev.filter((r) => r.id !== id); + }); + }; + + const handleRejectSampling = (id: number) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.reject(new Error("Sampling request rejected")); + if (request?.originatingTab) { + navigateToTab(request.originatingTab); + } + return prev.filter((r) => r.id !== id); + }); + }; + + const handleResolveElicitation = ( + id: number, + response: ElicitationResponse, + ) => { + setPendingElicitationRequests((prev) => { + const request = prev.find((r) => r.id === id); + if (request) { + request.resolve(response); + if (request.originatingTab) { + navigateToTab(request.originatingTab); + } + } + return prev.filter((r) => r.id !== id); + }); + }; + + const clearError = useCallback((tabKey: keyof typeof errors) => { + setErrors((prev) => ({ ...prev, [tabKey]: null })); + }, []); + + const listResources = async () => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + const result = await loadResourcesPage(nextResourceCursor, metadata); + setNextResourceCursor(result.nextCursor); + clearError("resources"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + resources: errorString, + })); + throw e; + } + }; + + const listResourceTemplates = async () => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + const result = await loadResourceTemplatesPage( + nextResourceTemplateCursor, + metadata, + ); + setNextResourceTemplateCursor(result.nextCursor); + clearError("resources"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + resources: errorString, + })); + throw e; + } + }; + + const getPrompt = async (name: string, args: Record = {}) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + // Convert string args to JsonValue for InspectorClient + const jsonArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + jsonArgs[key] = value; // strings are valid JsonValue + } + const response = await inspectorClient.getPrompt( + name, + jsonArgs, + metadata, + ); + setPromptContent(JSON.stringify(response, null, 2)); + clearError("prompts"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + prompts: errorString, + })); + throw e; + } + }; + + const readResource = async (uri: string) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + const response = await inspectorClient.readResource(uri, metadata); + const content = JSON.stringify(response, null, 2); + setResourceContent(content); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: content, + })); + clearError("resources"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + resources: errorString, + })); + throw e; + } + }; + + const subscribeToResource = async (uri: string) => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + await inspectorClient.subscribeToResource(uri); + // InspectorClient manages subscriptions internally, but we track them for UI + const clone = new Set(resourceSubscriptions); + clone.add(uri); + setResourceSubscriptions(clone); + clearError("resources"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + resources: errorString, + })); + throw e; + } + }; + + const unsubscribeFromResource = async (uri: string) => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + await inspectorClient.unsubscribeFromResource(uri); + // InspectorClient manages subscriptions internally, but we track them for UI + const clone = new Set(resourceSubscriptions); + clone.delete(uri); + setResourceSubscriptions(clone); + clearError("resources"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + resources: errorString, + })); + throw e; + } + }; + + const listPrompts = async () => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + const result = await loadPromptsPage(nextPromptCursor, metadata); + setNextPromptCursor(result.nextCursor); + clearError("prompts"); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + prompts: errorString, + })); + throw e; + } + }; + + const handleListTools = useCallback(async () => { + if (!pagedToolsState || !inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + clearError("tools"); + const result = await loadToolsPage(nextToolCursor); + setNextToolCursor(result.nextCursor); + cacheToolOutputSchemas(result.tools); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ + ...prev, + tools: errorString, + })); + throw e; + } + }, [ + pagedToolsState, + inspectorClient, + loadToolsPage, + nextToolCursor, + clearError, + ]); + + const handleClearTools = useCallback(() => { + clearPagedTools(); + setNextToolCursor(undefined); + cacheToolOutputSchemas([]); + clearError("tools"); + }, [clearPagedTools, clearError]); + + const listTasks = useCallback(async () => { + if (!inspectorClient) return; + try { + clearError("tasks"); + await loadTasksPage(nextTaskCursor); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ ...prev, tasks: errorString })); + } + }, [inspectorClient, loadTasksPage, nextTaskCursor, clearError]); + + const clearTasks = useCallback(() => { + clearPagedTasks(); + }, [clearPagedTasks]); + + const cancelTask = useCallback( + async (taskId: string) => { + if (!inspectorClient) return; + await inspectorClient.cancelRequestorTask(taskId); + setErrors((prev) => ({ ...prev, tasks: null })); + try { + await loadTasksPage(undefined); + } catch { + // ignore refresh error + } + }, + [inspectorClient, loadTasksPage], + ); + + // When switching to Tasks tab, load first page of tasks + useEffect(() => { + if ( + activeTab !== "tasks" || + !inspectorClient || + !serverCapabilities?.tasks + ) { + return; + } + loadTasksPage(undefined).catch((e) => { + const errorString = (e as Error).message ?? String(e); + setErrors((prev) => ({ ...prev, tasks: errorString })); + }); + }, [activeTab, inspectorClient, serverCapabilities?.tasks, loadTasksPage]); + + // When switching to Apps tab, ensure tools are listed so app tools are available + useEffect(() => { + if ( + connectionStatus === "connected" && + activeTab === "apps" && + serverCapabilities?.tools && + pagedTools.length === 0 + ) { + void loadToolsPage(); + } + }, [ + connectionStatus, + activeTab, + serverCapabilities?.tools, + pagedTools.length, + loadToolsPage, + ]); + + const callTool = async ( + name: string, + params: Record, + toolMetadata?: Record, + runAsTask?: boolean, + ) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + + const tool = pagedTools.find((t) => t.name === name); + if (!tool) { + throw new Error(`Tool "${name}" not found in tools list`); + } + const taskSupport = tool.execution?.taskSupport ?? "forbidden"; + const effectiveRunAsTask = + taskSupport === "required" || + (taskSupport === "optional" && runAsTask === true); + + const cleanedParams = tool.inputSchema + ? cleanParams(params, tool.inputSchema as JsonSchemaType) + : params; + const generalMetadata = { + ...metadata, + progressToken: String(progressTokenRef.current++), + }; + const toolSpecificMetadata = toolMetadata + ? Object.fromEntries( + Object.entries(toolMetadata).map(([k, v]) => [k, String(v)]), + ) + : undefined; + const taskOptions = effectiveRunAsTask + ? { ttl: getMCPTaskTtl(config) } + : undefined; + + try { + if (effectiveRunAsTask) { + // Use callToolStream for task-augmented execution (required or optional+checked) + let currentTaskId: string | undefined; + + const onToolCallTaskUpdated = ( + e: CustomEvent<{ + taskId: string; + task: { status: string; statusMessage?: string }; + }>, + ) => { + const { taskId, task } = e.detail; + if (currentTaskId === undefined) currentTaskId = taskId; + if (currentTaskId !== taskId) return; + const statusText = + task.status === "working" || task.status === "pending" + ? "Polling..." + : ""; + setToolResult({ + content: [ + { + type: "text", + text: `Task status: ${task.status}${task.statusMessage ? ` - ${task.statusMessage}` : ""}${statusText ? ` ${statusText}` : ""}`, + }, + ], + _meta: { + "io.modelcontextprotocol/related-task": { taskId }, + }, + } as CompatibilityCallToolResult); + // PagedRequestorTasksState already receives requestorTaskUpdated from callToolStream + }; + + inspectorClient.addEventListener( + "toolCallTaskUpdated", + onToolCallTaskUpdated, + ); + setIsPollingTask(true); + + try { + const invocation = await inspectorClient.callToolStream( + tool, + cleanedParams as Record, + generalMetadata, + toolSpecificMetadata, + taskOptions, + ); + + const compatibilityResult: CompatibilityCallToolResult = + invocation.result + ? { + ...invocation.result, + content: invocation.result.content ?? [], + } + : { + content: [ + { + type: "text", + text: invocation.error || "Tool call failed", + }, + ], + isError: true, + }; + setToolResult(compatibilityResult); + } finally { + inspectorClient.removeEventListener( + "toolCallTaskUpdated", + onToolCallTaskUpdated, + ); + setIsPollingTask(false); + } + } else { + // Use callTool for non-task execution + const invocation = await inspectorClient.callTool( + tool, + cleanedParams as Record, + generalMetadata, + toolSpecificMetadata, + undefined, // no task options + ); + + const compatibilityResult: CompatibilityCallToolResult = + invocation.result + ? { + ...invocation.result, + content: invocation.result.content ?? [], + } + : { + content: [ + { + type: "text", + text: invocation.error || "Tool call failed", + }, + ], + isError: true, + }; + setToolResult(compatibilityResult); + } + setErrors((prev) => ({ ...prev, tools: null })); + } catch (e) { + setIsPollingTask(false); + setToolResult({ + content: [ + { + type: "text", + text: (e as Error).message ?? String(e), + }, + ], + isError: true, + }); + setErrors((prev) => ({ ...prev, tools: null })); + } + }; + + const handleRootsChange = async () => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + // InspectorClient.setRoots() handles sending the notification internally + await inspectorClient.setRoots(roots); + }; + + const handleClearNotifications = () => { + if (messageLogState) { + messageLogState.clearMessages(notificationsMessagePredicate); + } else { + setNotifications([]); + } + }; + + const sendLogLevelRequest = async (level: LoggingLevel) => { + if (!inspectorClient) { + throw new Error("InspectorClient is not connected"); + } + try { + await inspectorClient.setLoggingLevel(level); + setLogLevel(level); + } catch (e) { + const errorString = (e as Error).message ?? String(e); + console.error("Failed to set logging level:", errorString); + throw e; + } + }; + + const AuthDebuggerWrapper = () => ( + + setIsAuthDebuggerVisible(false)} + /> + + ); + + // Check for OAuth callback params (even if pathname is wrong - some OAuth servers redirect incorrectly) + const urlParams = new URLSearchParams(window.location.search); + const hasOAuthCallbackParams = + urlParams.has("code") || urlParams.has("error"); + const stateParam = urlParams.get("state"); + const isGuidedOAuthCallback = + hasOAuthCallbackParams && + (window.location.pathname === "/oauth/callback" || + window.location.pathname === "/") && + stateParam != null && + parseOAuthState(stateParam)?.mode === "guided"; + + // Guided auth callback in another tab: show callback UI (code to copy) without requiring API token. + // That tab has its own sessionStorage and won't have the token from the opener tab. + if (isGuidedOAuthCallback) { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + + Loading... +
+ } + > + null} + logger={null} + onConnect={() => {}} + /> + + ); + } + + // Config fetch returned 401: show token screen; user submits token then we retry /api/config + if (configFetchStatus === "loading") { + return ( +
+ Loading... +
+ ); + } + if (configFetchStatus === "need_token") { + return ( + + ); + } + + // Handle OAuth callback - check pathname OR presence of callback params + // (Some OAuth servers redirect to root instead of /oauth/callback) + if ( + window.location.pathname === "/oauth/callback" || + (hasOAuthCallbackParams && window.location.pathname === "/") + ) { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...
}> + + + ); + } + + // If we have OAuth callback params but wrong pathname (and not root), log it + if ( + hasOAuthCallbackParams && + window.location.pathname !== "/oauth/callback" && + window.location.pathname !== "/" + ) { + console.warn( + "[App] OAuth callback params detected but unexpected pathname:", + { + pathname: window.location.pathname, + search: window.location.search, + fullUrl: window.location.href, + }, + ); + } + + return ( +
+
+ +
+
+
+
+ {connectionStatus === "connected" ? ( + { + setActiveTab(value); + window.location.hash = value; + }} + > + + + + Resources + + + + Prompts + + + + Tools + + + + Tasks + + + + Apps + + + + Ping + + {transportType !== "stdio" && ( + + + Requests + + )} + + + Sampling + {pendingSampleRequests.length > 0 && ( + + {pendingSampleRequests.length} + + )} + + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + + + + Roots + + {transportType === "stdio" && ( + + + Console + + )} + + + Auth + + + + Metadata + + + +
+ {!serverCapabilities?.resources && + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? ( + <> +
+

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

+
+ { + if (!inspectorClient) { + throw new Error("MCP client is not connected"); + } + try { + await inspectorClient.ping(); + } catch (e) { + console.error("Ping failed:", e); + throw e; + } + }} + /> + + ) : ( + <> + { + clearError("resources"); + listResources(); + }} + clearResources={() => { + clearPagedResources(); + setNextResourceCursor(undefined); + }} + listResourceTemplates={() => { + clearError("resources"); + listResourceTemplates(); + }} + clearResourceTemplates={() => { + clearPagedResourceTemplates(); + 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={() => { + clearPagedPrompts(); + 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"); + void handleListTools(); + }} + error={errors.tools} + appRendererClient={appRendererClient} + onNotification={(notification) => { + setNotifications((prev) => [...prev, notification]); + }} + /> + { + clearError("tools"); + void handleListTools(); + }} + clearTools={handleClearTools} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + await callTool(name, params, metadata, runAsTask); + }} + selectedTool={selectedTool} + setSelectedTool={(tool) => { + clearError("tools"); + setSelectedTool(tool); + setToolResult(null); + }} + toolResult={toolResult} + isPollingTask={isPollingTask} + serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} + nextCursor={nextToolCursor} + error={errors.tools} + resourceContent={resourceContentMap} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} + /> + { + clearError("tasks"); + void listTasks(); + }} + clearTasks={() => { + clearTasks(); + setSelectedTask(null); + }} + cancelTask={cancelTask} + selectedTask={selectedTask} + setSelectedTask={setSelectedTask} + error={errors.tasks} + nextCursor={nextTaskCursor} + /> + + { + if (!inspectorClient) { + throw new Error("MCP client is not connected"); + } + try { + await inspectorClient.ping(); + } catch (e) { + console.error("Ping failed:", e); + throw e; + } + }} + /> + {transportType !== "stdio" && ( + + )} + + + + + + + )} +
+
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > + + + ) : ( +
+

+ Connect to an MCP server to start inspecting +

+
+

+ Need to configure authentication? +

+ +
+
+ )} +
+
+
+
+
+
+ +
+
+
+
+ ); +}; + +export default App; diff --git a/client/src/__mocks__/styleMock.js b/web/src/__mocks__/styleMock.js similarity index 100% rename from client/src/__mocks__/styleMock.js rename to web/src/__mocks__/styleMock.js diff --git a/web/src/__tests__/App.config.test.tsx b/web/src/__tests__/App.config.test.tsx new file mode 100644 index 000000000..0b5e254bd --- /dev/null +++ b/web/src/__tests__/App.config.test.tsx @@ -0,0 +1,134 @@ +import type { Mock } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import App from "../App"; +import { DEFAULT_INSPECTOR_CONFIG } from "../lib/constants"; +import { InspectorConfig } from "../lib/configurationTypes"; +import * as configUtils from "../utils/configUtils"; + +// Mock auth dependencies first +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: vi.fn(), +})); + +// Mock the config utils (async factory so we can spread importActual) +vi.mock("../utils/configUtils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getInitialTransportType: vi.fn(() => "stdio"), + getInitialSseUrl: vi.fn(() => "http://localhost:3001/sse"), + getInitialCommand: vi.fn(() => "mcp-server-everything"), + getInitialArgs: vi.fn(() => ""), + getInspectorApiToken: vi.fn( + (config: InspectorConfig) => + config.MCP_INSPECTOR_API_TOKEN?.value || undefined, + ), + initializeInspectorConfig: vi.fn(() => DEFAULT_INSPECTOR_CONFIG), + saveInspectorConfig: vi.fn(), + }; +}); + +// Get references to the mocked functions +const mockInitializeInspectorConfig = + configUtils.initializeInspectorConfig as Mock; + +// Mock InspectorClient hook +vi.mock( + "@modelcontextprotocol/inspector-core/react/useInspectorClient.js", + () => ({ + useInspectorClient: () => ({ + status: "disconnected", + capabilities: null, + serverInfo: null, + instructions: undefined, + appRendererClient: null, + connect: vi.fn(), + disconnect: vi.fn(), + }), + }), +); + +vi.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: vi.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: vi.fn(), + }), +})); + +vi.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +// Mock fetch +global.fetch = vi.fn(); + +describe("App - Config Endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as Mock).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + defaultEnvironment: { TEST_ENV: "test" }, + defaultCommand: "test-command", + defaultArgs: ["test-arg1", "test-arg2"], + defaultTransport: "stdio", + defaultServerUrl: "", + sandboxUrl: "http://localhost:12345/sandbox", + }), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("fetches /api/config when API token is present and applies response", async () => { + const mockConfig = { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_INSPECTOR_API_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_INSPECTOR_API_TOKEN, + value: "test-api-token", + }, + }; + + mockInitializeInspectorConfig.mockReturnValue(mockConfig); + + render(); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/config"), + expect.objectContaining({ + headers: { + "x-mcp-remote-auth": "Bearer test-api-token", + }, + }), + ); + }); + }); + + test("when /api/config includes sandboxUrl, app completes config gate and shows main UI", async () => { + mockInitializeInspectorConfig.mockReturnValue({ + ...DEFAULT_INSPECTOR_CONFIG, + MCP_INSPECTOR_API_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_INSPECTOR_API_TOKEN, + value: "token", + }, + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/Connect to an MCP server to start inspecting/i), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/__tests__/App.routing.test.tsx b/web/src/__tests__/App.routing.test.tsx new file mode 100644 index 000000000..f8b6e4bab --- /dev/null +++ b/web/src/__tests__/App.routing.test.tsx @@ -0,0 +1,168 @@ +import { render, waitFor } from "@testing-library/react"; +import type { UseInspectorClientResult } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; +import App from "../App"; +import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; + +// Mock auth dependencies first +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: vi.fn(), +})); + +// Mock the config utils (async factory so we can spread importActual) +vi.mock("../utils/configUtils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getInitialTransportType: vi.fn(() => "stdio"), + getInitialSseUrl: vi.fn(() => "http://localhost:3001/sse"), + getInitialCommand: vi.fn(() => "mcp-server-everything"), + getInitialArgs: vi.fn(() => ""), + initializeInspectorConfig: vi.fn(() => ({ + MCP_INSPECTOR_API_TOKEN: { + label: "API Token", + description: + "Auth token for authenticating with the Inspector API server", + value: "test-token", + is_session_item: true, + }, + })), + saveInspectorConfig: vi.fn(), + }; +}); + +// Default connection state is disconnected (cast for mock) +const disconnectedInspectorClientState: UseInspectorClientResult = { + status: "disconnected", + capabilities: {}, + serverInfo: { name: "", version: "" }, + instructions: undefined, + appRendererClient: null, + connect: vi.fn(), + disconnect: vi.fn(), +}; + +// Connected state for tests that need an active connection +const connectedInspectorClientState: UseInspectorClientResult = { + ...disconnectedInspectorClientState, + status: "connected", + capabilities: {}, + appRendererClient: {} as UseInspectorClientResult["appRendererClient"], // Mock - needed for hash setting logic + serverInfo: { name: "", version: "" }, +}; + +// Mock required dependencies, but unrelated to routing. +vi.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: vi.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: vi.fn(), + }), +})); + +vi.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +// Mock fetch +global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); + +// Mock InspectorClient hook +vi.mock( + "@modelcontextprotocol/inspector-core/react/useInspectorClient.js", + () => ({ + useInspectorClient: vi.fn(), + }), +); + +// jsdom does not provide window.matchMedia; useTheme calls it. +const mockMatchMedia = (matches = false) => ({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + media: "", +}); + +describe("App - URL Fragment Routing", () => { + const mockUseInspectorClient = vi.mocked(useInspectorClient); + + beforeEach(() => { + vi.restoreAllMocks(); + + window.matchMedia = vi + .fn() + .mockImplementation((_query: string) => + mockMatchMedia(false), + ) as unknown as typeof window.matchMedia; + + // Inspector starts disconnected. + mockUseInspectorClient.mockReturnValue(disconnectedInspectorClientState); + }); + + test("does not set hash when starting disconnected", async () => { + render(); + + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); + + test("sets default hash based on server capabilities priority", async () => { + // Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth + // + // Server capabilities determine the first three tabs; if none are present, falls back to Ping. + + const testCases = [ + { + capabilities: { resources: { listChanged: true, subscribe: true } }, + expected: "#resources", + }, + { + capabilities: { prompts: { listChanged: true, subscribe: true } }, + expected: "#prompts", + }, + { + capabilities: { tools: { listChanged: true, subscribe: true } }, + expected: "#tools", + }, + { capabilities: {}, expected: "#ping" }, + ]; + + const { rerender } = render(); + + for (const { capabilities, expected } of testCases) { + window.location.hash = ""; + mockUseInspectorClient.mockReturnValue({ + ...connectedInspectorClientState, + capabilities, + }); + + rerender(); + + await waitFor(() => { + expect(window.location.hash).toBe(expected); + }); + } + }); + + test("clears hash when disconnected", async () => { + // Start with a hash set (simulating a connection) + window.location.hash = "#resources"; + + // App starts disconnected (default mock) + render(); + + // Should clear the hash when disconnected + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); +}); diff --git a/web/src/__tests__/App.samplingNavigation.test.tsx b/web/src/__tests__/App.samplingNavigation.test.tsx new file mode 100644 index 000000000..f337d7338 --- /dev/null +++ b/web/src/__tests__/App.samplingNavigation.test.tsx @@ -0,0 +1,489 @@ +/** + * UX tests: when a sampling request arrives (via InspectorClient newPendingSample event), + * the App shows it in the sampling tab and Approve/Reject call the correct callbacks. + * + * Mirrors client/src/__tests__/App.samplingNavigation.test.tsx. The client injects + * the request via useConnection's onPendingRequest; the web App subscribes via + * inspectorClient.addEventListener("newPendingSample", ...). We mock InspectorClient + * only to capture that listener and emit one event with the same detail shape the real + * client uses (shared/mcp/samplingCreateMessage.ts SamplingCreateMessage: id, request, + * respond, reject). Sampling behavior itself is tested in shared/__tests__/inspectorClient.test.ts. + */ +import { + render, + screen, + fireEvent, + waitFor, + act, + within, +} from "@testing-library/react"; +import App from "../App"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { + CreateMessageRequest, + CreateMessageResult, +} from "@modelcontextprotocol/sdk/types.js"; +function renderApp() { + return render( + + + , + ); +} + +// Event payload shape matches shared SamplingCreateMessage (id, request, respond, reject) +type NewPendingSampleDetail = { + id: string; + request: CreateMessageRequest; + respond: (result: CreateMessageResult) => Promise; + reject: (error: Error) => Promise; +}; + +const newPendingSampleListeners = new Set< + (e: CustomEvent) => void +>(); + +let dispatchNewPendingSample: + | ((detail: NewPendingSampleDetail) => void) + | null = null; + +function createFakeInspectorClient() { + const client = { + addEventListener( + type: string, + handler: (e: CustomEvent) => void, + ) { + if (type === "newPendingSample") { + newPendingSampleListeners.add(handler); + } + }, + removeEventListener( + _type: string, + handler: (e: CustomEvent) => void, + ) { + newPendingSampleListeners.delete(handler); + }, + getStatus: () => "connected" as const, + getMessages: () => [], + getStderrLogs: () => [], + getFetchRequests: () => [], + getTools: () => [], + getResources: () => [], + getResourceTemplates: () => [], + getPrompts: () => [], + getCapabilities: () => ({ resources: {}, prompts: {}, tools: {} }), + getServerInfo: () => ({ name: "", version: "" }), + getInstructions: () => undefined, + getAppRendererClient: () => ({}), + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + getRoots: () => [], + }; + + dispatchNewPendingSample = (detail: NewPendingSampleDetail) => { + const event = new CustomEvent("newPendingSample", { detail }); + newPendingSampleListeners.forEach((h) => h(event)); + }; + + return client; +} + +vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: vi.fn(), +})); + +vi.mock("../utils/configUtils", async (importOriginal) => { + const actual = await importOriginal(); + const { DEFAULT_INSPECTOR_CONFIG } = await import("../lib/constants"); + const configWithToken = { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_INSPECTOR_API_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_INSPECTOR_API_TOKEN, + value: "test-token", + }, + }; + return { + ...actual, + getInspectorApiToken: vi.fn(() => "test-token"), + getInitialTransportType: vi.fn(() => "stdio"), + getInitialSseUrl: vi.fn(() => "http://localhost:3001/sse"), + getInitialCommand: vi.fn(() => "mcp-server-everything"), + getInitialArgs: vi.fn(() => ""), + initializeInspectorConfig: vi.fn(() => configWithToken), + saveInspectorConfig: vi.fn(), + }; +}); + +// Stub so ensureInspectorClient completes and setInspectorClient(ourFake) runs (no throw before new InspectorClient) +vi.mock("../lib/adapters/environmentFactory", () => ({ + createWebEnvironment: vi.fn(() => ({ + transport: vi.fn(), + fetch: vi + .fn() + .mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }), + logger: { log: vi.fn() }, + oauth: undefined, + })), +})); + +vi.mock( + "@modelcontextprotocol/inspector-core/mcp/index.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@modelcontextprotocol/inspector-core/mcp/index.js") + >(); + function MockInspectorClient() { + return createFakeInspectorClient(); + } + return { + ...actual, + InspectorClient: vi.fn().mockImplementation(MockInspectorClient), + }; + }, +); + +// Wrap real hook: when App has a client in state, force status "connected" so main pane shows Tabs +vi.mock( + "@modelcontextprotocol/inspector-core/react/useInspectorClient.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@modelcontextprotocol/inspector-core/react/useInspectorClient.js") + >(); + return { + useInspectorClient: (client: unknown) => { + const result = actual.useInspectorClient(client as never); + return { ...result, status: client ? "connected" : result.status }; + }, + }; + }, +); + +vi.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: vi.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: vi.fn(), + }), +})); + +// /api/config must return 200 + ok so app passes config gate and shows main UI +global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + defaultCommand: "mcp-server-everything", + defaultArgs: [], + defaultTransport: "stdio", + defaultServerUrl: "", + defaultEnvironment: {}, + }), +}); + +// jsdom does not provide window.matchMedia; useTheme (via TokenLoginScreen/main UI) calls it. +const mockMatchMedia = (matches = false) => ({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + media: "", +}); + +// Same request shape as client test and shared CreateMessageRequest for sampling +const sampleRequest: CreateMessageRequest = { + method: "sampling/createMessage", + params: { messages: [], maxTokens: 1 }, +}; + +describe("App - Sampling navigation", () => { + beforeEach(() => { + dispatchNewPendingSample = null; + newPendingSampleListeners.clear(); + window.location.hash = "#tools"; + window.matchMedia = vi + .fn() + .mockImplementation((_query: string) => + mockMatchMedia(false), + ) as unknown as typeof window.matchMedia; + }); + + it("Step 3: Connect shows tabs including Sampling", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /Connect/i }), + ).toBeInTheDocument(); + }); + + const connectButton = screen.getByRole("button", { name: /Connect/i }); + await act(async () => { + fireEvent.click(connectButton); + }); + + await waitFor( + () => { + expect( + screen.getByRole("tab", { name: /Sampling/i }), + ).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("after Connect, selecting Sampling tab shows 'No pending requests'", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /Connect/i }), + ).toBeInTheDocument(); + }); + + const connectButton = screen.getByRole("button", { name: /Connect/i }); + await act(async () => { + fireEvent.click(connectButton); + }); + + await waitFor( + () => { + expect( + screen.getByRole("tab", { name: /Sampling/i }), + ).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + // Radix TabsTrigger: data-state="active" | "inactive". Verify Sampling is present and which tab is active. + const getActiveTabName = () => { + const active = document.querySelector( + '[role="tab"][data-state="active"]', + ); + return active?.textContent?.trim() ?? null; + }; + const samplingTab = screen.getByRole("tab", { name: /Sampling/i }); + const isSamplingActive = () => + samplingTab.getAttribute("data-state") === "active"; + + const initialActive = getActiveTabName(); + expect(samplingTab).toBeInTheDocument(); + expect(samplingTab.getAttribute("data-state")).toBeDefined(); + // Sampling is present but not active; we will select it and then assert it becomes active. + expect(isSamplingActive()).toBe(false); + + // Select Sampling by hash only (drives App activeTab; one consistent path). + if (!isSamplingActive()) { + await act(() => { + window.location.hash = "#sampling"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + await waitFor(() => expect(isSamplingActive()).toBe(true), { + timeout: 2000, + }); + if (!isSamplingActive()) { + throw new Error( + `Could not switch to Sampling. Initial active: "${initialActive}". Sampling data-state: "${samplingTab.getAttribute("data-state")}".`, + ); + } + } + + expect(isSamplingActive()).toBe(true); + // Verify we see the empty state (Sampling tab content). Scope to the Tabs root that owns the tab triggers. + const tabsRoot = samplingTab.closest("[role='tablist']")?.parentElement; + expect(tabsRoot).toBeTruthy(); + await waitFor( + () => { + expect( + within(tabsRoot!).getByText(/No pending requests/i), + ).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("Step 4: after inducing a sampling request, Sampling tab shows the request", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /Connect/i }), + ).toBeInTheDocument(); + }); + + const connectButton = screen.getByRole("button", { name: /Connect/i }); + await act(async () => { + fireEvent.click(connectButton); + }); + + await waitFor( + () => { + expect(dispatchNewPendingSample).not.toBeNull(); + expect(newPendingSampleListeners.size).toBeGreaterThan(0); + }, + { timeout: 3000 }, + ); + + await waitFor( + () => { + expect( + screen.getByRole("tab", { name: /Sampling/i }), + ).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + + await act(() => { + dispatchNewPendingSample!({ + id: "sample-step4", + request: sampleRequest, + respond: vi.fn().mockResolvedValue(undefined), + reject: vi.fn().mockResolvedValue(undefined), + }); + }); + + await act(() => { + window.location.hash = "#sampling"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + await waitFor( + () => { + expect(screen.getByTestId("sampling-request")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + }); + + it("shows sampling request in sampling tab and Approve resolves it", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /Connect/i }), + ).toBeInTheDocument(); + }); + + const connectButton = screen.getByRole("button", { name: /Connect/i }); + await act(async () => { + fireEvent.click(connectButton); + }); + + await waitFor( + () => { + expect(dispatchNewPendingSample).not.toBeNull(); + expect(newPendingSampleListeners.size).toBeGreaterThan(0); + }, + { timeout: 3000 }, + ); + + await waitFor( + () => { + expect( + screen.getByRole("tab", { name: /Sampling/i }), + ).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + + const respondFn = vi.fn().mockResolvedValue(undefined); + const rejectFn = vi.fn().mockResolvedValue(undefined); + + await act(() => { + dispatchNewPendingSample!({ + id: "sample-1", + request: sampleRequest, + respond: respondFn, + reject: rejectFn, + }); + }); + + await act(() => { + window.location.hash = "#sampling"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + + const samplingTab = screen.getByRole("tab", { name: /Sampling/i }); + await act(async () => { + fireEvent.click(samplingTab); + }); + + await waitFor( + () => { + expect(screen.getByTestId("sampling-request")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + + const approveButton = screen.getByRole("button", { name: /Approve/i }); + await act(async () => { + fireEvent.click(approveButton); + }); + + await waitFor(() => { + expect(respondFn).toHaveBeenCalled(); + }); + }); + + it("shows sampling request and Reject calls reject", async () => { + renderApp(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /Connect/i }), + ).toBeInTheDocument(); + }); + const connectButton = screen.getByRole("button", { name: /Connect/i }); + await act(async () => { + fireEvent.click(connectButton); + }); + + // Connected when Sampling tab exists (main pane with our Tabs is rendered) + await waitFor(() => { + expect( + screen.getByRole("tab", { name: /Sampling/i }), + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(dispatchNewPendingSample).not.toBeNull(); + expect(newPendingSampleListeners.size).toBeGreaterThan(0); + }); + + const rejectFn = vi.fn().mockResolvedValue(undefined); + await act(() => { + dispatchNewPendingSample!({ + id: "sample-2", + request: sampleRequest, + respond: vi.fn().mockResolvedValue(undefined), + reject: rejectFn, + }); + }); + + // Wait for sampling UI to be in the document (same as Approve test) so Reject is wired to our callback + await waitFor( + () => { + expect(screen.getByTestId("sampling-request")).toBeInTheDocument(); + }, + { timeout: 3000 }, + ); + const rejectButton = within( + screen.getByTestId("sampling-request"), + ).getByRole("button", { name: /Reject/i }); + await act(async () => { + fireEvent.click(rejectButton); + }); + await act(async () => {}); + + await waitFor(() => { + expect(rejectFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/components/AppRenderer.tsx b/web/src/components/AppRenderer.tsx similarity index 63% rename from client/src/components/AppRenderer.tsx rename to web/src/components/AppRenderer.tsx index e0c259809..9a671574d 100644 --- a/client/src/components/AppRenderer.tsx +++ b/web/src/components/AppRenderer.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState } from "react"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { useMemo, useState, useEffect, useRef } from "react"; +import type { AppRendererClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import { Tool, ContentBlock, ServerNotification, LoggingMessageNotificationParams, + type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; import { AppRenderer as McpUiAppRenderer, @@ -22,19 +23,29 @@ import { useToast } from "@/lib/hooks/useToast"; interface AppRendererProps { sandboxPath: string; tool: Tool; - mcpClient: Client | null; + appRendererClient: AppRendererClient | null; toolInput?: Record; onNotification?: (notification: ServerNotification) => void; } +type ToolResultState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: unknown } + | { status: "error"; error: string }; + const AppRenderer = ({ sandboxPath, tool, - mcpClient, + appRendererClient, toolInput, onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); + const [toolResultState, setToolResultState] = useState({ + status: "idle", + }); + const runIdRef = useRef(0); const { toast } = useToast(); const hostContext: McpUiHostContext = useMemo( @@ -46,6 +57,30 @@ const AppRenderer = ({ [], ); + // When tool and toolInput are ready, call tools/call and pass result to the app (PR 1075 tool-result) + useEffect(() => { + if (!appRendererClient || !tool?.name) return; + + const args = toolInput ?? {}; + const currentRun = ++runIdRef.current; + setToolResultState({ status: "loading" }); + + appRendererClient + .callTool({ + name: tool.name, + arguments: args as Record, + }) + .then((result) => { + if (currentRun !== runIdRef.current) return; + setToolResultState({ status: "success", result }); + }) + .catch((err: unknown) => { + if (currentRun !== runIdRef.current) return; + const message = err instanceof Error ? err.message : String(err); + setToolResultState({ status: "error", error: message }); + }); + }, [appRendererClient, tool?.name, toolInput]); + const handleOpenLink = async ({ url }: { url: string }) => { let isError = true; if (url.startsWith("https://") || url.startsWith("http://")) { @@ -57,7 +92,6 @@ const AppRenderer = ({ const handleMessage = async ( params: McpUiMessageRequest["params"], - // eslint-disable-next-line @typescript-eslint/no-unused-vars _extra: RequestHandlerExtra, ): Promise => { const message = params.content @@ -85,7 +119,14 @@ const AppRenderer = ({ } }; - if (!mcpClient) { + const toolResult = + toolResultState.status === "success" + ? toolResultState.result + : toolResultState.status === "error" + ? { content: [{ type: "text" as const, text: toolResultState.error }] } + : undefined; + + if (!appRendererClient) { return ( @@ -108,13 +149,14 @@ const AppRenderer = ({ style={{ minHeight: "400px" }} > void; error: string | null; - mcpClient: Client | null; + appRendererClient: AppRendererClient | null; onNotification?: (notification: ServerNotification) => void; } -// Type guard to check if a tool has UI metadata const hasUIMetadata = (tool: Tool): boolean => { return !!getToolUiResourceUri(tool); }; @@ -55,7 +55,7 @@ const AppsTab = ({ tools, listTools, error, - mcpClient, + appRendererClient, onNotification, }: AppsTabProps) => { const [appTools, setAppTools] = useState([]); @@ -66,7 +66,6 @@ const AppsTab = ({ const [hasValidationErrors, setHasValidationErrors] = useState(false); const formRefs = useRef>({}); - // Function to check if any form has validation errors const checkValidationErrors = useCallback(() => { const errors = Object.values(formRefs.current).some( (ref) => ref && !ref.validateJson().isValid, @@ -75,17 +74,10 @@ const AppsTab = ({ 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); @@ -94,24 +86,25 @@ const AppsTab = ({ 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)); + const initialParams = Object.fromEntries( + Object.entries(selectedTool.inputSchema?.properties ?? {}).map( + ([key, value]) => { + const resolvedValue = resolveRef( + value as JsonSchemaType, + selectedTool.inputSchema as JsonSchemaType, + ); + return [ + key, + generateDefaultValue( + resolvedValue, + key, + selectedTool.inputSchema as JsonSchemaType, + ), + ]; + }, + ), + ); + setParams(initialParams); setHasValidationErrors(false); formRefs.current = {}; } else { @@ -137,7 +130,7 @@ const AppsTab = ({ const handleSelectTool = useCallback((tool: Tool) => { setSelectedTool(tool); const hasFields = - tool.inputSchema.properties && + tool.inputSchema?.properties && Object.keys(tool.inputSchema.properties).length > 0; setIsAppOpen(!hasFields); }, []); @@ -236,10 +229,10 @@ const AppsTab = ({ )} - {selectedTool ? ( + {selectedTool && sandboxPath ? ( (() => { const hasFields = - selectedTool.inputSchema.properties && + selectedTool.inputSchema?.properties && Object.keys(selectedTool.inputSchema.properties).length > 0; return ( @@ -255,9 +248,8 @@ const AppsTab = ({

App Input

{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, @@ -296,8 +288,10 @@ const AppsTab = ({ ? null : prop.type === "array" ? undefined - : prop.default !== null - ? prop.default + : (prop as JsonSchemaType) + .default !== null + ? (prop as JsonSchemaType) + .default : prop.type === "boolean" ? false : prop.type === "string" @@ -365,10 +359,10 @@ const AppsTab = ({ {prop.enum.map((option) => ( - {option} + {String(option)} ))} @@ -496,7 +490,7 @@ const AppsTab = ({ @@ -506,6 +500,17 @@ const AppsTab = ({
); })() + ) : !sandboxPath ? ( +
+ +

Sandbox not configured

+

+ The server did not provide a sandbox URL. MCP Apps require the + server to include{" "} + sandboxUrl in{" "} + /api/config. +

+
) : (
diff --git a/web/src/components/AuthDebugger.tsx b/web/src/components/AuthDebugger.tsx new file mode 100644 index 000000000..ad164451a --- /dev/null +++ b/web/src/components/AuthDebugger.tsx @@ -0,0 +1,320 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertCircle } from "lucide-react"; +import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { silentLogger } from "@modelcontextprotocol/inspector-core/logging"; +import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; +import type { WebEnvironmentResult } from "@/lib/adapters/environmentFactory"; +import { OAuthFlowProgress } from "./OAuthFlowProgress"; +import { useToast } from "@/lib/hooks/useToast"; + +export interface AuthDebuggerProps { + inspectorClient: InspectorClient | null; + ensureInspectorClient: () => InspectorClient | null; + canCreateInspectorClient: () => boolean; + /** Logger from the same env as InspectorClient (for OAuth/auth logging). */ + logger?: WebEnvironmentResult["logger"] | null; + onBack: () => void; +} + +interface StatusMessageProps { + message: { type: "error" | "success" | "info"; message: string }; +} + +const StatusMessage = ({ message }: StatusMessageProps) => { + let bgColor: string; + let textColor: string; + let borderColor: string; + + switch (message.type) { + case "error": + bgColor = "bg-red-50"; + textColor = "text-red-700"; + borderColor = "border-red-200"; + break; + case "success": + bgColor = "bg-green-50"; + textColor = "text-green-700"; + borderColor = "border-green-200"; + break; + case "info": + default: + bgColor = "bg-blue-50"; + textColor = "text-blue-700"; + borderColor = "border-blue-200"; + break; + } + + return ( +
+
+ +

{message.message}

+
+
+ ); +}; + +const AuthDebugger = ({ + inspectorClient, + ensureInspectorClient, + canCreateInspectorClient, + logger, + onBack, +}: AuthDebuggerProps) => { + const log = logger ?? silentLogger; + const { toast } = useToast(); + const [oauthState, setOauthState] = useState( + undefined, + ); + const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); + + // Sync oauthState from InspectorClient (TUI pattern) + useEffect(() => { + if (!inspectorClient) { + setOauthState(undefined); + return; + } + + const update = () => setOauthState(inspectorClient.getOAuthState()); + update(); + + const onStepChange = () => update(); + inspectorClient.addEventListener("oauthStepChange", onStepChange); + inspectorClient.addEventListener("oauthComplete", onStepChange); + inspectorClient.addEventListener("oauthError", onStepChange); + + return () => { + inspectorClient.removeEventListener("oauthStepChange", onStepChange); + inspectorClient.removeEventListener("oauthComplete", onStepChange); + inspectorClient.removeEventListener("oauthError", onStepChange); + }; + }, [inspectorClient]); + + // Check for existing tokens on mount + useEffect(() => { + if (inspectorClient && !oauthState?.oauthTokens) { + inspectorClient.getOAuthTokens().then((tokens) => { + if (tokens) { + // State will be updated via getOAuthState() in sync effect + setOauthState(inspectorClient.getOAuthState()); + } + }); + } + }, [inspectorClient, oauthState]); + + const handleQuickOAuth = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client) { + return; // Error already shown in ensureInspectorClient + } + + setIsInitiatingAuth(true); + try { + // Quick Auth: normal flow (automatic redirect via BrowserNavigation) + const authUrl = await client.authenticate(); + // Log via app-provided logger (same as InspectorClient's env logger) + log.info( + { + component: "AuthDebugger", + action: "authenticate", + authorizationUrl: authUrl.href, + redirectUri: authUrl.searchParams.get("redirect_uri"), + expectedRedirectUri: `${window.location.origin}/oauth/callback`, + currentOrigin: window.location.origin, + currentPathname: window.location.pathname, + }, + "OAuth authorization URL generated - about to redirect", + ); + // BrowserNavigation handles redirect automatically + } catch (error) { + log.error({ err: error }, "Quick OAuth failed"); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [ensureInspectorClient, log, toast]); + + const handleGuidedOAuth = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client) { + return; // Error already shown in ensureInspectorClient + } + + setIsInitiatingAuth(true); + try { + // Start guided flow + await client.beginGuidedAuth(); + // State updates via oauthStepChange events (handled in useEffect above) + } catch (error) { + log.error({ err: error }, "Guided OAuth start failed"); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [ensureInspectorClient, log, toast]); + + const proceedToNextStep = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client || !oauthState) { + if (!client) { + // Error already shown in ensureInspectorClient + return; + } + return; // No oauthState, nothing to proceed + } + + setIsInitiatingAuth(true); + try { + await client.proceedOAuthStep(); + // Note: For guided flow, users manually copy the authorization code. + // There's a manual button in OAuthFlowProgress to open the URL if needed. + // Quick auth handles redirects automatically via BrowserNavigation. + } catch (error) { + log.error({ err: error }, "OAuth step failed"); + toast({ + title: "OAuth Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }, [ensureInspectorClient, log, oauthState, toast]); + + const handleClearOAuth = useCallback(async () => { + const client = ensureInspectorClient(); + if (!client) { + return; // Error already shown in ensureInspectorClient + } + + try { + client.clearOAuthTokens(); + toast({ + title: "OAuth Cleared", + description: "OAuth tokens cleared successfully", + variant: "default", + }); + } catch (error) { + log.error({ err: error }, "Clear OAuth failed"); + toast({ + title: "Error", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + } + }, [ensureInspectorClient, log, toast]); + + return ( +
+
+

Authentication Settings

+ +
+ +
+
+
+

+ Configure authentication settings for your MCP server connection. +

+ +
+

OAuth Authentication

+

+ Use OAuth to securely authenticate with the MCP server. +

+ + {oauthState?.latestError && ( + + )} + +
+ {oauthState?.oauthTokens && ( +
+

Access Token:

+
+ {oauthState.oauthTokens.access_token.substring(0, 25)}... +
+
+ )} + +
+ + + + + +
+ {!inspectorClient && !canCreateInspectorClient() && ( +

+ API Token is required. Please set it in Configuration. +

+ )} + +

+ Choose "Guided" for step-by-step instructions or "Quick" for + the standard automatic flow. +

+
+
+ + +
+
+
+
+ ); +}; + +export default AuthDebugger; diff --git a/web/src/components/ConsoleTab.tsx b/web/src/components/ConsoleTab.tsx new file mode 100644 index 000000000..a244b7e85 --- /dev/null +++ b/web/src/components/ConsoleTab.tsx @@ -0,0 +1,44 @@ +import { TabsContent } from "@/components/ui/tabs"; +import type { StderrLogEntry } from "@modelcontextprotocol/inspector-core/mcp/index.js"; + +interface ConsoleTabProps { + stderrLogs: StderrLogEntry[]; +} + +const ConsoleTab = ({ stderrLogs }: ConsoleTabProps) => { + const formatTimestamp = (timestamp: Date) => { + return new Date(timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + // @ts-expect-error - fractionalSecondDigits is valid but not in TypeScript types yet + fractionalSecondDigits: 3, + }); + }; + + return ( + +
+ {stderrLogs.length === 0 ? ( +
No stderr output yet
+ ) : ( +
+ {stderrLogs.map((log, index) => ( +
+ + {formatTimestamp(log.timestamp)} + + + {log.message} + +
+ ))} +
+ )} +
+
+ ); +}; + +export default ConsoleTab; diff --git a/client/src/components/CustomHeaders.tsx b/web/src/components/CustomHeaders.tsx similarity index 100% rename from client/src/components/CustomHeaders.tsx rename to web/src/components/CustomHeaders.tsx diff --git a/client/src/components/DynamicJsonForm.tsx b/web/src/components/DynamicJsonForm.tsx similarity index 99% rename from client/src/components/DynamicJsonForm.tsx rename to web/src/components/DynamicJsonForm.tsx index ecd150f22..624aac34d 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/web/src/components/DynamicJsonForm.tsx @@ -28,7 +28,6 @@ interface DynamicJsonFormProps { export interface DynamicJsonFormRef { validateJson: () => { isValid: boolean; error: string | null }; - hasJsonError: () => boolean; } const isTypeSupported = ( @@ -128,11 +127,9 @@ 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>( + undefined, + ); // Debounce JSON parsing and parent updates to handle typing gracefully const debouncedUpdateParent = useCallback( @@ -258,7 +255,6 @@ const DynamicJsonForm = forwardRef( useImperativeHandle(ref, () => ({ validateJson, - hasJsonError, })); const renderFormFields = ( diff --git a/client/src/components/ElicitationRequest.tsx b/web/src/components/ElicitationRequest.tsx similarity index 98% rename from client/src/components/ElicitationRequest.tsx rename to web/src/components/ElicitationRequest.tsx index 4488a9620..d68fd325d 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/web/src/components/ElicitationRequest.tsx @@ -5,13 +5,13 @@ import JsonView from "./JsonView"; import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; import { - PendingElicitationRequest, + PendingFormElicitationRequest, ElicitationResponse, } from "./ElicitationTab"; import Ajv from "ajv"; export type ElicitationRequestProps = { - request: PendingElicitationRequest; + request: PendingFormElicitationRequest; onResolve: (id: number, response: ElicitationResponse) => void; }; diff --git a/web/src/components/ElicitationTab.tsx b/web/src/components/ElicitationTab.tsx new file mode 100644 index 000000000..edffdb261 --- /dev/null +++ b/web/src/components/ElicitationTab.tsx @@ -0,0 +1,92 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { TabsContent } from "@/components/ui/tabs"; +import { JsonSchemaType } from "@/utils/jsonUtils"; +import ElicitationRequest from "./ElicitationRequest"; +import ElicitationUrlRequest from "./ElicitationUrlRequest"; + +/** Form-mode elicitation request payload */ +export interface FormElicitationRequestData { + mode: "form"; + id: number; + message: string; + requestedSchema: JsonSchemaType; +} + +/** URL-mode elicitation request payload */ +export interface UrlElicitationRequestData { + mode: "url"; + id: number; + message: string; + url: string; + elicitationId: string; +} + +export type ElicitationRequestData = + | FormElicitationRequestData + | UrlElicitationRequestData; + +export interface ElicitationResponse { + action: "accept" | "decline" | "cancel"; + content?: Record; +} + +export type PendingElicitationRequest = { + id: number; + /** Client-side id (ElicitationCreateMessage.id) for syncing with getPendingElicitations() */ + elicitationId: string; + request: ElicitationRequestData; + originatingTab?: string; +}; + +/** Pending form-only request; use for ElicitationRequest component */ +export type PendingFormElicitationRequest = PendingElicitationRequest & { + request: FormElicitationRequestData; +}; + +/** Pending URL-only request; use for ElicitationUrlRequest component */ +export type PendingUrlElicitationRequest = PendingElicitationRequest & { + request: UrlElicitationRequestData; +}; + +export type Props = { + pendingRequests: PendingElicitationRequest[]; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { + return ( + +
+ + + When the server requests information from the user, requests will + appear here for response. + + +
+

Recent Requests

+ {pendingRequests.map((request) => + request.request.mode === "url" ? ( + + ) : ( + + ), + )} + {pendingRequests.length === 0 && ( +

No pending requests

+ )} +
+
+
+ ); +}; + +export default ElicitationTab; diff --git a/web/src/components/ElicitationUrlRequest.tsx b/web/src/components/ElicitationUrlRequest.tsx new file mode 100644 index 000000000..eb0be09a1 --- /dev/null +++ b/web/src/components/ElicitationUrlRequest.tsx @@ -0,0 +1,165 @@ +import { useState, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertTriangle, ExternalLink, Copy, XCircle, Ban } from "lucide-react"; +import { + PendingUrlElicitationRequest, + ElicitationResponse, +} from "./ElicitationTab"; + +export type ElicitationUrlRequestProps = { + request: PendingUrlElicitationRequest; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +function getUrlWarnings(url: string): string[] { + const warnings: string[] = []; + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + warnings.push("URL does not use HTTPS. Only open links you trust."); + } + if (/[^\x20-\x7E]/.test(url)) { + warnings.push( + "URL contains non-ASCII characters. Check the address to avoid homograph attacks.", + ); + } + } catch { + warnings.push("URL is not a valid link."); + } + return warnings; +} + +const ElicitationUrlRequest = ({ + request, + onResolve, +}: ElicitationUrlRequestProps) => { + const [copyFeedback, setCopyFeedback] = useState(null); + const { message, url } = request.request; + + const warnings = useMemo(() => getUrlWarnings(url), [url]); + + const handleAcceptAndOpen = () => { + window.open(url, "_blank", "noopener,noreferrer"); + onResolve(request.id, { action: "accept" }); + }; + + const handleAccept = () => { + onResolve(request.id, { action: "accept" }); + }; + + const handleDecline = () => { + onResolve(request.id, { action: "decline" }); + }; + + const handleCancel = () => { + onResolve(request.id, { action: "cancel" }); + }; + + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(url); + setCopyFeedback("Copied!"); + setTimeout(() => setCopyFeedback(null), 2000); + } catch { + setCopyFeedback("Failed to copy"); + setTimeout(() => setCopyFeedback(null), 2000); + } + }; + + return ( +
+
+
+

URL Elicitation

+

{message}

+
+ +
+
URL
+
+ + {url} + + + {copyFeedback && ( + + {copyFeedback} + + )} +
+
+ + {warnings.length > 0 && ( + + + Security warning + +
    + {warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+
+ )} + +
+ + + + +
+
+
+ ); +}; + +export default ElicitationUrlRequest; diff --git a/client/src/components/HistoryAndNotifications.tsx b/web/src/components/HistoryAndNotifications.tsx similarity index 100% rename from client/src/components/HistoryAndNotifications.tsx rename to web/src/components/HistoryAndNotifications.tsx diff --git a/client/src/components/IconDisplay.tsx b/web/src/components/IconDisplay.tsx similarity index 100% rename from client/src/components/IconDisplay.tsx rename to web/src/components/IconDisplay.tsx diff --git a/client/src/components/JsonEditor.tsx b/web/src/components/JsonEditor.tsx similarity index 100% rename from client/src/components/JsonEditor.tsx rename to web/src/components/JsonEditor.tsx diff --git a/client/src/components/JsonView.tsx b/web/src/components/JsonView.tsx similarity index 100% rename from client/src/components/JsonView.tsx rename to web/src/components/JsonView.tsx diff --git a/client/src/components/ListPane.tsx b/web/src/components/ListPane.tsx similarity index 86% rename from client/src/components/ListPane.tsx rename to web/src/components/ListPane.tsx index e88aadb5a..b93f047da 100644 --- a/client/src/components/ListPane.tsx +++ b/web/src/components/ListPane.tsx @@ -12,6 +12,8 @@ type ListPaneProps = { title: string; buttonText: string; isButtonDisabled?: boolean; + /** When true, the list scroll area fills available vertical space instead of max-h-96 */ + fillHeight?: boolean; }; const ListPane = ({ @@ -23,6 +25,7 @@ const ListPane = ({ title, buttonText, isButtonDisabled, + fillHeight = false, }: ListPaneProps) => { const [searchQuery, setSearchQuery] = useState(""); const [isSearchExpanded, setIsSearchExpanded] = useState(false); @@ -56,8 +59,10 @@ const ListPane = ({ }; return ( -
-
+
+

{title} @@ -92,7 +97,9 @@ const ListPane = ({

-
+
- {clearItems && ( + {clearItems != null && ( )} -
+
{filteredItems.map((item, index) => (
void; +}) { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ + title: "Copy failed", + description: "Could not copy to clipboard", + variant: "destructive", + }); + } + }, [code, toast]); + + return ( +
+
+
+
+ +

+ MCP Inspector +

+
+

+ Please copy this authorization code and return to the Guided Auth + flow: +

+
+ + {code} + +
+ {copied && ( + Copied! + )} + +
+
+

+ Close this tab and paste the code in the OAuth flow to complete + authentication. +

+
+
+
+ ); +} + +interface OAuthCallbackProps { + inspectorClient: InspectorClient | null; + ensureInspectorClient: () => InspectorClient | null; + /** Logger from the same env as InspectorClient (for OAuth callback logging). */ + logger?: WebEnvironmentResult["logger"] | null; + onConnect: () => void; +} + +const OAuthCallback = ({ + inspectorClient, + ensureInspectorClient, + logger, + onConnect, +}: OAuthCallbackProps) => { + const log = logger ?? silentLogger; + useTheme(); // Apply saved theme to document so standalone (e.g. new-tab) callback obeys theme + const { toast } = useToast(); + const hasProcessedRef = useRef(false); + + useEffect(() => { + const handleCallback = async () => { + if (hasProcessedRef.current) return; + hasProcessedRef.current = true; + + // Parse params first to check if this is guided mode with no client + const params = parseOAuthCallbackParams(window.location.search); + const urlParams = new URLSearchParams(window.location.search); + const stateParam = urlParams.get("state"); + const parsedState = stateParam ? parseOAuthState(stateParam) : null; + const isGuidedMode = parsedState?.mode === "guided"; + + // If guided mode and no client available (new tab scenario), don't process, just display + // Skip ensureInspectorClient() here to avoid showing "API token required" toast + // since we're going to show the code for manual copying anyway + if (isGuidedMode && !inspectorClient) { + // If we have a code, store it and return early + if (params.successful && params.code) { + // Store code in sessionStorage for potential auto-fill (future enhancement) + if (parsedState?.authId) { + sessionStorage.setItem( + `oauth_guided_code_${parsedState.authId}`, + params.code, + ); + } + // Don't redirect - let the component render the code display + return; + } + // Even without a code yet, don't try to create a client in guided mode without one + return; + } + + // Ensure InspectorClient exists (it might not exist if page was refreshed) + // At this point, we're either not in guided mode, or we're in guided mode WITH a client + const client = inspectorClient || ensureInspectorClient(); + + // Log via app-provided logger (same as InspectorClient's env logger) + log.info( + { + component: "OAuthCallback", + action: "callback_received", + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + fullUrl: window.location.href, + expectedPathname: "/oauth/callback", + }, + "OAuth callback handler invoked", + ); + const notifyError = (description: string) => + void toast({ + title: "OAuth Authorization Error", + description, + variant: "destructive", + }); + + log.info( + { + component: "OAuthCallback", + action: "parse_params", + successful: params.successful, + hasCode: + params.successful && "code" in params ? !!params.code : false, + hasState: !!stateParam, + state: stateParam, + error: params.successful ? null : params.error, + errorDescription: params.successful ? null : params.error_description, + }, + "Parsed OAuth callback parameters", + ); + + if (!params.successful) { + log.error( + { + component: "OAuthCallback", + action: "callback_error", + error: params.error, + errorDescription: params.error_description, + }, + "OAuth callback parameters indicate failure", + ); + return notifyError(generateOAuthErrorDescription(params)); + } + + if (!params.code) { + return notifyError("Missing authorization code"); + } + + log.info( + { + component: "OAuthCallback", + action: "detect_mode", + parsedState, + isGuidedMode, + hasClient: !!client, + currentOAuthStep: client?.getOAuthStep(), + }, + "Detected OAuth flow mode", + ); + + // If no client and not guided mode, show error + if (!client) { + toast({ + title: "Error", + description: + "InspectorClient is not available. Please ensure API token is set and try connecting again.", + variant: "destructive", + }); + return; + } + + try { + if (isGuidedMode) { + // Guided mode: set authorization code without proceeding + // User will click "Next" in Auth Debugger to proceed + const currentStep = client.getOAuthStep(); + if (currentStep !== "authorization_code") { + log.warn( + { + component: "OAuthCallback", + action: "unexpected_step", + currentStep, + expectedStep: "authorization_code", + }, + "Received authorization code but not at authorization_code step", + ); + return notifyError( + `Unexpected OAuth step: ${currentStep}. Expected: authorization_code`, + ); + } + + await client.setGuidedAuthorizationCode(params.code, false); + + log.info( + { + component: "OAuthCallback", + action: "code_set_for_guided", + }, + "Authorization code set for guided flow. User should proceed manually.", + ); + + toast({ + title: "Authorization Code Received", + description: + "Return to the Auth Debugger and click 'Next' to continue.", + variant: "default", + }); + + // Don't redirect in guided mode - user needs to see the code or return manually + // Don't auto-connect in guided mode - user controls progression + } else { + // Normal mode: complete the flow automatically + await client.completeOAuthFlow(params.code); + + toast({ + title: "Success", + description: "Successfully authenticated with OAuth", + variant: "default", + }); + + // Connect first, then navigate. If we navigate first we lose the callback URL + // (state param with sessionId), so a retry or connect from main page wouldn't restore session. + await client.connect(); + onConnect(); + const targetPath = "/" + (window.location.hash || ""); + window.history.replaceState({}, document.title, targetPath); + } + } catch (error) { + console.error("OAuth callback error:", error); + log.error( + { + component: "OAuthCallback", + action: "callback_error", + error: error instanceof Error ? error.message : String(error), + }, + "OAuth callback processing failed", + ); + return notifyError( + `OAuth flow failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + handleCallback(); + }, [inspectorClient, ensureInspectorClient, log, toast, onConnect]); + + // Extract state and code for display (for rendering before useEffect runs) + const urlParams = new URLSearchParams(window.location.search); + const stateParam = urlParams.get("state"); + const parsedState = stateParam ? parseOAuthState(stateParam) : null; + const isGuidedMode = parsedState?.mode === "guided"; + const params = parseOAuthCallbackParams(window.location.search); + const hasCode = params.successful && "code" in params && !!params.code; + + // If guided mode and no client, show code for manual copying + // Check both /oauth/callback and / paths (in case redirect happened) + // Don't call ensureInspectorClient() here - it will fail in new tab and cause loops + const isCallbackPath = + window.location.pathname === "/oauth/callback" || + window.location.pathname === "/"; + if (isCallbackPath && isGuidedMode && !inspectorClient && hasCode) { + return ; + } + + return ( +
+

+ Processing OAuth callback... +

+
+ ); +}; + +export default OAuthCallback; diff --git a/web/src/components/OAuthDebugCallback.tsx b/web/src/components/OAuthDebugCallback.tsx new file mode 100644 index 000000000..eb7ce7708 --- /dev/null +++ b/web/src/components/OAuthDebugCallback.tsx @@ -0,0 +1,79 @@ +import { useEffect } from "react"; +import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { parseOAuthCallbackParams } from "@/utils/oauthUtils.ts"; + +interface OAuthDebugCallbackProps { + inspectorClient: InspectorClient | null; + ensureInspectorClient: () => InspectorClient | null; + onConnect: () => void; +} + +const OAuthDebugCallback = ({ + inspectorClient, + ensureInspectorClient, + onConnect, +}: OAuthDebugCallbackProps) => { + useEffect(() => { + let isProcessed = false; + + const handleCallback = async () => { + if (isProcessed) return; + isProcessed = true; + + // Ensure InspectorClient exists (it might not exist if page was refreshed) + const client = inspectorClient || ensureInspectorClient(); + if (!client) { + console.error("OAuth debug callback: InspectorClient not available"); + return; + } + + const params = parseOAuthCallbackParams(window.location.search); + if (!params.successful || !params.code) { + // Display error in UI (already handled by component rendering) + return; + } + + // For debug flow, we still need to complete the flow manually + // The guided flow state is managed by InspectorClient internally + try { + await client.completeOAuthFlow(params.code); + onConnect(); + } catch (error) { + console.error("OAuth debug callback error:", error); + } + }; + + handleCallback().finally(() => { + if (window.location.pathname !== "/oauth/callback/debug") { + window.history.replaceState({}, document.title, "/"); + } + }); + + return () => { + isProcessed = true; + }; + }, [inspectorClient, ensureInspectorClient, onConnect]); + + const callbackParams = parseOAuthCallbackParams(window.location.search); + + return ( +
+
+

+ Please copy this authorization code and return to the Auth Debugger: +

+ + {callbackParams.successful && "code" in callbackParams + ? callbackParams.code + : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} + +

+ Close this tab and paste the code in the OAuth flow to complete + authentication. +

+
+
+ ); +}; + +export default OAuthDebugCallback; diff --git a/client/src/components/OAuthFlowProgress.tsx b/web/src/components/OAuthFlowProgress.tsx similarity index 60% rename from client/src/components/OAuthFlowProgress.tsx rename to web/src/components/OAuthFlowProgress.tsx index 6e0fd6956..789847866 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/web/src/components/OAuthFlowProgress.tsx @@ -1,9 +1,10 @@ -import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; +import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; +import type { OAuthStep } from "@modelcontextprotocol/inspector-core/auth/types.js"; +import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; import { Button } from "./ui/button"; -import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; -import { useEffect, useMemo, useState } from "react"; -import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { useEffect, useState } from "react"; +import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { validateRedirectUrl } from "@/utils/urlValidation"; import { useToast } from "@/lib/hooks/useToast"; @@ -52,10 +53,9 @@ const OAuthStepDetails = ({ }; interface OAuthFlowProgressProps { - serverUrl: string; - authState: AuthDebuggerState; - updateAuthState: (updates: Partial) => void; + oauthState: AuthGuidedState | undefined; proceedToNextStep: () => Promise; + ensureInspectorClient: () => InspectorClient | null; } const steps: Array = [ @@ -68,55 +68,43 @@ const steps: Array = [ ]; export const OAuthFlowProgress = ({ - serverUrl, - authState, - updateAuthState, + oauthState, proceedToNextStep, + ensureInspectorClient, }: OAuthFlowProgressProps) => { const { toast } = useToast(); - const provider = useMemo( - () => new DebugInspectorOAuthClientProvider(serverUrl), - [serverUrl], - ); const [clientInfo, setClientInfo] = useState( null, ); + // Local state for authorization code input (synced from oauthState but allows typing) + const [localAuthCode, setLocalAuthCode] = useState(""); - const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); - + // Sync local state from oauthState when it changes useEffect(() => { - const fetchClientInfo = async () => { - if (authState.oauthClientInfo) { - setClientInfo(authState.oauthClientInfo); - } else { - try { - const info = await provider.clientInformation(); - if (info) { - setClientInfo(info); - } - } catch (error) { - console.error("Failed to fetch client information:", error); - } - } - }; + if (oauthState?.authorizationCode) { + setLocalAuthCode(oauthState.authorizationCode); + } else { + setLocalAuthCode(""); + } + }, [oauthState?.authorizationCode]); - if (currentStepIdx > steps.indexOf("client_registration")) { - fetchClientInfo(); + const currentStepIdx = oauthState + ? steps.findIndex((s) => s === oauthState.oauthStep) + : -1; + + useEffect(() => { + if (oauthState?.oauthClientInfo) { + setClientInfo(oauthState.oauthClientInfo); } - }, [ - provider, - authState.oauthStep, - authState.oauthClientInfo, - currentStepIdx, - ]); + }, [oauthState]); // Helper to get step props const getStepProps = (stepName: OAuthStep) => ({ isComplete: currentStepIdx > steps.indexOf(stepName) || currentStepIdx === steps.length - 1, // last step is "complete" - isCurrent: authState.oauthStep === stepName, - error: authState.oauthStep === stepName ? authState.latestError : null, + isCurrent: oauthState?.oauthStep === stepName, + error: oauthState?.oauthStep === stepName ? oauthState.latestError : null, }); return ( @@ -131,53 +119,26 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {authState.oauthMetadata && ( + {oauthState?.oauthMetadata && (
OAuth Metadata Sources - {!authState.resourceMetadata && " ā„¹ļø"} + {!oauthState.resourceMetadata && " ā„¹ļø"} - {authState.resourceMetadata && ( + {oauthState.resourceMetadata && (

Resource Metadata:

-

- From{" "} - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } -

-                    {JSON.stringify(authState.resourceMetadata, null, 2)}
+                    {JSON.stringify(oauthState.resourceMetadata, null, 2)}
                   
)} - {authState.resourceMetadataError && ( + {oauthState.resourceMetadataError && (

- ā„¹ļø Problem with resource metadata from{" "} - - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } - + ā„¹ļø Problem with resource metadata

Resource metadata was added in the{" "} @@ -185,29 +146,29 @@ export const OAuthFlowProgress = ({ 2025-06-18 specification update
- {authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError && + {oauthState.resourceMetadataError.message} + {oauthState.resourceMetadataError instanceof TypeError && " (This could indicate the endpoint doesn't exist or does not have CORS configured)"}

)} - {authState.oauthMetadata && ( + {oauthState.oauthMetadata && (

Authorization Server Metadata:

- {authState.authServerUrl && ( + {oauthState.authServerUrl && (

From{" "} { new URL( "/.well-known/oauth-authorization-server", - authState.authServerUrl, + oauthState.authServerUrl, ).href }

)}
-                    {JSON.stringify(authState.oauthMetadata, null, 2)}
+                    {JSON.stringify(oauthState.oauthMetadata, null, 2)}
                   
)} @@ -235,19 +196,33 @@ export const OAuthFlowProgress = ({ label="Preparing Authorization" {...getStepProps("authorization_redirect")} > - {authState.authorizationUrl && ( + {oauthState?.authorizationUrl && (

Authorization URL:

- {String(authState.authorizationUrl)} + {String(oauthState.authorizationUrl)}

)} @@ -291,22 +272,54 @@ export const OAuthFlowProgress = ({
{ - updateAuthState({ - authorizationCode: e.target.value, - validationError: null, - }); + setLocalAuthCode(e.target.value); + }} + onBlur={async () => { + const code = localAuthCode.trim(); + if (!code) return; + + const client = ensureInspectorClient(); + if (!client) { + toast({ + title: "Error", + description: + "InspectorClient is not available. Please ensure API token is set.", + variant: "destructive", + }); + return; + } + + try { + await client.setGuidedAuthorizationCode(code, false); + } catch (error) { + toast({ + title: "Error", + description: + error instanceof Error + ? error.message + : "Failed to set authorization code", + variant: "destructive", + }); + } + }} + onKeyDown={async (e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); // Trigger onBlur which sets the code + } }} placeholder="Enter the code from the authorization server" className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ - authState.validationError ? "border-red-500" : "border-input" + oauthState?.validationError + ? "border-red-500" + : "border-input" }`} />
- {authState.validationError && ( + {oauthState?.validationError && (

- {authState.validationError} + {oauthState.validationError}

)}

@@ -320,7 +333,7 @@ export const OAuthFlowProgress = ({ label="Token Request" {...getStepProps("token_request")} > - {authState.oauthMetadata && ( + {oauthState?.oauthMetadata && (

Token Request Details @@ -328,7 +341,7 @@ export const OAuthFlowProgress = ({

Token Endpoint:

- {authState.oauthMetadata.token_endpoint} + {oauthState.oauthMetadata.token_endpoint}
@@ -339,7 +352,7 @@ export const OAuthFlowProgress = ({ label="Authentication Complete" {...getStepProps("complete")} > - {authState.oauthTokens && ( + {oauthState?.oauthTokens && (
Access Tokens @@ -350,7 +363,7 @@ export const OAuthFlowProgress = ({ requests.

-                {JSON.stringify(authState.oauthTokens, null, 2)}
+                {JSON.stringify(oauthState.oauthTokens, null, 2)}
               
)} @@ -358,25 +371,53 @@ export const OAuthFlowProgress = ({
- {authState.oauthStep !== "complete" && ( + {oauthState && oauthState.oauthStep !== "complete" && ( <> )} - {authState.oauthStep === "authorization_redirect" && - authState.authorizationUrl && ( + {oauthState?.oauthStep === "authorization_redirect" && + oauthState.authorizationUrl && (
+ + + setOauthClientMetadataUrl(e.target.value) + } + value={oauthClientMetadataUrl} + data-testid="oauth-client-metadata-url-input" + className="font-mono" + /> @@ -640,82 +644,103 @@ const Sidebar = ({ {showConfig && (
- {Object.entries(config).map(([key, configItem]) => { - const configKey = key as keyof InspectorConfig; - return ( -
-
-
- ); - })} + ); + })}
)}
@@ -726,6 +751,19 @@ const Sidebar = ({
)} {connectionStatus !== "connected" && ( - @@ -754,8 +810,6 @@ const Sidebar = ({ return "bg-green-500"; case "error": return "bg-red-500"; - case "error-connecting-to-proxy": - return "bg-red-500"; default: return "bg-gray-500"; } @@ -767,14 +821,12 @@ const Sidebar = ({ case "connected": return "Connected"; case "error": { - const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value; - if (!hasProxyToken) { - return "Connection Error - Did you add the proxy session token in Configuration?"; + const hasApiToken = config.MCP_INSPECTOR_API_TOKEN?.value; + if (!hasApiToken && !authAcceptedWithoutToken) { + return "Connection Error - Did you add the API token in Configuration?"; } - return "Connection Error - Check if your MCP server is running and proxy token is correct"; + return "Connection Error - Check if your MCP server is running and API token is correct"; } - case "error-connecting-to-proxy": - return "Error Connecting to MCP Inspector Proxy - Check Console logs"; default: return "Disconnected"; } diff --git a/client/src/components/TasksTab.tsx b/web/src/components/TasksTab.tsx similarity index 98% rename from client/src/components/TasksTab.tsx rename to web/src/components/TasksTab.tsx index 32ffa7d46..8c1c227c8 100644 --- a/client/src/components/TasksTab.tsx +++ b/web/src/components/TasksTab.tsx @@ -1,7 +1,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { TabsContent } from "@/components/ui/tabs"; -import { Task } from "@modelcontextprotocol/sdk/types.js"; +import type { Task } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, RefreshCw, @@ -79,6 +79,7 @@ const TasksTab = ({ clearItems={clearTasks} buttonText={nextCursor ? "List More Tasks" : "List Tasks"} isButtonDisabled={!nextCursor && tasks.length > 0} + fillHeight renderItem={(task) => (
diff --git a/web/src/components/TokenLoginScreen.tsx b/web/src/components/TokenLoginScreen.tsx new file mode 100644 index 000000000..71835e4f2 --- /dev/null +++ b/web/src/components/TokenLoginScreen.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import useTheme from "@/lib/hooks/useTheme"; +import { KeyRound } from "lucide-react"; + +export interface TokenLoginScreenProps { + onTokenSubmit: (token: string) => void; + /** Error from server (e.g. 401 after submitting token) */ + serverError?: string | null; +} + +/** + * Shown when the app loads without an API token (e.g. direct navigation). + * User enters the token (provided when running the inspector from the CLI); + * on submit we persist it and load the main app. + */ +const TokenLoginScreen = ({ + onTokenSubmit, + serverError = null, +}: TokenLoginScreenProps) => { + useTheme(); // Apply saved theme so new-tab / direct load obeys theme + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + + const displayError = serverError ?? error; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = token.trim(); + if (!trimmed) { + setError("Please enter an API token."); + return; + } + setError(null); + onTokenSubmit(trimmed); + }; + + return ( +
+
+
+
+ +

+ MCP Inspector +

+
+

+ Enter the API token to continue. The token is provided when you run + the inspector from the CLI (e.g.{" "} + + npm run web + + ) and is included in the URL when the browser opens. +

+ +
+ + { + setToken(e.target.value); + setError(null); + }} + className="font-mono text-sm" + autoFocus + autoComplete="off" + /> +
+ {displayError && ( +

+ {displayError} +

+ )} + + +
+
+
+ ); +}; + +export default TokenLoginScreen; diff --git a/client/src/components/ToolResults.tsx b/web/src/components/ToolResults.tsx similarity index 88% rename from client/src/components/ToolResults.tsx rename to web/src/components/ToolResults.tsx index 38d1d0382..af47f0862 100644 --- a/client/src/components/ToolResults.tsx +++ b/web/src/components/ToolResults.tsx @@ -22,17 +22,14 @@ const checkContentCompatibility = ( text?: string; [key: string]: unknown; }>, -): { isCompatible: boolean; message: string } => { +): { hasMatch: boolean; message: string } | null => { // Look for at least one text content block that matches the structured content const textBlocks = unstructuredContent.filter( (block) => block.type === "text", ); if (textBlocks.length === 0) { - return { - isCompatible: false, - message: "No text blocks to match structured content", - }; + return null; } // Check if any text block contains JSON that matches the structured content @@ -49,7 +46,7 @@ const checkContentCompatibility = ( if (isEqual) { return { - isCompatible: true, + hasMatch: true, message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`, }; } @@ -59,10 +56,7 @@ const checkContentCompatibility = ( } } - return { - isCompatible: false, - message: "No text block matches structured content", - }; + return null; }; const ToolResults = ({ @@ -70,7 +64,7 @@ const ToolResults = ({ selectedTool, resourceContent, onReadResource, - isPollingTask, + isPollingTask = false, }: ToolResultsProps) => { if (!toolResult) return null; @@ -91,18 +85,19 @@ const ToolResults = ({ const structuredResult = parsedResult.data; const isError = structuredResult.isError ?? false; - // Check if this is a running task + // Task running detection (inspector-main parity) const relatedTask = structuredResult._meta?.[ "io.modelcontextprotocol/related-task" ] as { taskId: string } | undefined; + const contentText = structuredResult.content + .filter((c) => c.type === "text") + .map((c) => ("text" in c && typeof c.text === "string" ? c.text : "")) + .join(" "); const isTaskRunning = isPollingTask || (!!relatedTask && - structuredResult.content.some( - (c) => - c.type === "text" && - (c.text?.includes("Polling") || c.text?.includes("Task status")), - )); + (contentText.includes("Polling") || + contentText.includes("Task status"))); let validationResult = null; const toolHasOutputSchema = @@ -195,16 +190,9 @@ const ToolResults = ({
Unstructured Content:
- {compatibilityResult && ( -
- {compatibilityResult.isCompatible ? "āœ“" : "⚠"}{" "} - {compatibilityResult.message} + {compatibilityResult?.hasMatch && ( +
+ āœ“ {compatibilityResult.message}
)} diff --git a/client/src/components/ToolsTab.tsx b/web/src/components/ToolsTab.tsx similarity index 85% rename from client/src/components/ToolsTab.tsx rename to web/src/components/ToolsTab.tsx index c581e98a6..6090a46de 100644 --- a/client/src/components/ToolsTab.tsx +++ b/web/src/components/ToolsTab.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { @@ -64,7 +65,8 @@ const ToolsTab = ({ selectedTool, setSelectedTool, toolResult, - isPollingTask, + isPollingTask = false, + serverSupportsTaskToolCalls = false, nextCursor, error, resourceContent, @@ -83,14 +85,18 @@ const ToolsTab = ({ setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; isPollingTask?: boolean; + serverSupportsTaskToolCalls?: boolean; nextCursor: ListToolsResult["nextCursor"]; error: string | null; resourceContent: Record; onReadResource?: (uri: string) => void; }) => { const [params, setParams] = useState>({}); - const [runAsTask, setRunAsTask] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false); + const [runAsTask, setRunAsTask] = useState(false); + const taskSupport = selectedTool?.execution?.taskSupport ?? "forbidden"; + const effectiveRunAsTask = + taskSupport === "required" || (taskSupport === "optional" && runAsTask); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetadataExpanded, setIsMetadataExpanded] = useState(false); const [metadataEntries, setMetadataEntries] = useState< @@ -102,11 +108,9 @@ const ToolsTab = ({ const { copied, setCopied } = useCopy(); // 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; @@ -131,7 +135,6 @@ const ToolsTab = ({ ]; }); setParams(Object.fromEntries(params)); - setRunAsTask(false); // Reset validation errors when switching tools setHasValidationErrors(false); @@ -140,6 +143,13 @@ const ToolsTab = ({ formRefs.current = {}; }, [selectedTool]); + // Sync runAsTask when selected tool changes: required -> checked, forbidden -> unchecked + useEffect(() => { + const support = selectedTool?.execution?.taskSupport ?? "forbidden"; + if (support === "required") setRunAsTask(true); + else if (support === "forbidden") setRunAsTask(false); + }, [selectedTool?.execution?.taskSupport, selectedTool?.name]); + const hasReservedMetadataEntry = metadataEntries.some(({ key }) => { const trimmedKey = key.trim(); return trimmedKey !== "" && isReservedMetaKey(trimmedKey); @@ -156,15 +166,15 @@ const ToolsTab = ({ }); return ( - -
+ +
{ clearTools(); setSelectedTool(null); - setRunAsTask(false); }} setSelectedItem={setSelectedTool} renderItem={(tool) => ( @@ -173,7 +183,7 @@ const ToolsTab = ({
- {tool.title || tool.name} + {tool.name} {tool.description} @@ -186,8 +196,8 @@ const ToolsTab = ({ isButtonDisabled={!nextCursor && tools.length > 0} /> -
-
+
+
{selectedTool && ( )}

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

-
+
{selectedTool ? (
{error && ( @@ -368,7 +376,9 @@ const ToolsTab = ({ prop.type === "array" ? (
(formRefs.current[key] = ref)} + ref={(ref) => { + formRefs.current[key] = ref; + }} schema={{ type: prop.type, properties: prop.properties, @@ -431,7 +441,9 @@ const ToolsTab = ({ ) : (
(formRefs.current[key] = ref)} + ref={(ref) => { + formRefs.current[key] = ref; + }} schema={{ type: prop.type, properties: prop.properties, @@ -661,73 +673,85 @@ const ToolsTab = ({
)} -
- - setRunAsTask(checked) +
+ {selectedTool && serverSupportsTaskToolCalls && ( +
+ + setRunAsTask(checked === true) + } + /> + +
+ )} +
-
); - }; -}); + }, +})); describe("ElicitationRequest", () => { - const mockOnResolve = jest.fn(); + const mockOnResolve = vi.fn(); beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); const createMockRequest = ( - overrides: Partial = {}, - ): PendingElicitationRequest => ({ + overrides: Partial = {}, + ): PendingFormElicitationRequest => ({ id: 1, + elicitationId: "elicitation-1", request: { + mode: "form", id: 1, message: "Please provide your information", requestedSchema: { @@ -66,7 +67,7 @@ describe("ElicitationRequest", () => { }); const renderElicitationRequest = ( - request: PendingElicitationRequest = createMockRequest(), + request: PendingFormElicitationRequest = createMockRequest(), ) => { return render( , @@ -84,6 +85,7 @@ describe("ElicitationRequest", () => { renderElicitationRequest( createMockRequest({ request: { + mode: "form", id: 1, message, requestedSchema: { diff --git a/web/src/components/__tests__/ElicitationTab.test.tsx b/web/src/components/__tests__/ElicitationTab.test.tsx new file mode 100644 index 000000000..58853032e --- /dev/null +++ b/web/src/components/__tests__/ElicitationTab.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { Tabs } from "@/components/ui/tabs"; +import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab"; + +describe("Elicitation tab", () => { + const mockOnResolve = vi.fn(); + + const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) => + render( + + + , + ); + + it("should render 'No pending requests' when there are no pending requests", () => { + renderElicitationTab([]); + expect( + screen.getByText( + "When the server requests information from the user, requests will appear here for response.", + ), + ).toBeTruthy(); + expect(screen.getByText("No pending requests")).toBeInTheDocument(); + }); + + it("should render the correct number of form requests", () => { + renderElicitationTab( + Array.from({ length: 3 }, (_, i) => ({ + id: i, + elicitationId: `elicitation-${i}`, + request: { + mode: "form" as const, + id: i, + message: `Please provide information ${i}`, + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Your name", + }, + }, + required: ["name"], + }, + }, + })), + ); + expect(screen.getAllByTestId("elicitation-request").length).toBe(3); + }); + + it("should render URL elicitation requests with ElicitationUrlRequest", () => { + renderElicitationTab([ + { + id: 0, + elicitationId: "url-elicitation-1", + request: { + mode: "url", + id: 0, + message: "Open this URL to complete", + url: "https://example.com/complete", + elicitationId: "url-elicitation-1", + }, + }, + ]); + expect(screen.getByTestId("elicitation-url-request")).toBeInTheDocument(); + expect(screen.getByText("Open this URL to complete")).toBeInTheDocument(); + expect( + screen.getByText("https://example.com/complete"), + ).toBeInTheDocument(); + }); + + it("should render mix of form and URL requests", () => { + renderElicitationTab([ + { + id: 0, + elicitationId: "form-1", + request: { + mode: "form", + id: 0, + message: "Form request", + requestedSchema: { + type: "object", + properties: { x: { type: "string" } }, + }, + }, + }, + { + id: 1, + elicitationId: "url-1", + request: { + mode: "url", + id: 1, + message: "URL request", + url: "https://example.com", + elicitationId: "url-1", + }, + }, + ]); + expect(screen.getByTestId("elicitation-request")).toBeInTheDocument(); + expect(screen.getByTestId("elicitation-url-request")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/__tests__/ElicitationUrlRequest.test.tsx b/web/src/components/__tests__/ElicitationUrlRequest.test.tsx new file mode 100644 index 000000000..f033b122b --- /dev/null +++ b/web/src/components/__tests__/ElicitationUrlRequest.test.tsx @@ -0,0 +1,199 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import ElicitationUrlRequest from "../ElicitationUrlRequest"; +import { PendingUrlElicitationRequest } from "../ElicitationTab"; + +describe("ElicitationUrlRequest", () => { + const mockOnResolve = vi.fn(); + + const createMockRequest = ( + overrides: Partial = {}, + ): PendingUrlElicitationRequest => ({ + id: 1, + elicitationId: "url-elicitation-1", + request: { + mode: "url", + id: 1, + message: "Please open this URL to complete the flow", + url: "https://example.com/complete", + elicitationId: "url-elicitation-1", + }, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("should render message and URL", () => { + const request = createMockRequest(); + render( + , + ); + expect( + screen.getByText("Please open this URL to complete the flow"), + ).toBeInTheDocument(); + expect( + screen.getByText("https://example.com/complete"), + ).toBeInTheDocument(); + }); + + it("should render all action buttons", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: /accept and open url/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^accept$/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /decline/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByTestId("copy-url-button")).toBeInTheDocument(); + }); + + it("should call onResolve with accept when Accept and open URL is clicked", () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: /accept and open url/i }), + ); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/complete", + "_blank", + "noopener,noreferrer", + ); + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "accept" }); + openSpy.mockRestore(); + }); + + it("should call onResolve with accept when Accept is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^accept$/i })); + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "accept" }); + }); + + it("should call onResolve with decline when Decline is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /decline/i })); + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" }); + }); + + it("should call onResolve with cancel when Cancel is clicked", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" }); + }); + + it("should show security warning for non-HTTPS URL", () => { + render( + , + ); + expect(screen.getByTestId("url-warnings")).toBeInTheDocument(); + expect(screen.getByText(/URL does not use HTTPS/i)).toBeInTheDocument(); + }); + + it("should show security warning for invalid URL", () => { + render( + , + ); + expect(screen.getByTestId("url-warnings")).toBeInTheDocument(); + expect(screen.getByText(/URL is not a valid link/i)).toBeInTheDocument(); + }); + + it("should show security warning for URL with non-ASCII characters", () => { + render( + , + ); + expect(screen.getByTestId("url-warnings")).toBeInTheDocument(); + expect( + screen.getByText(/non-ASCII characters.*homograph/i), + ).toBeInTheDocument(); + }); + + it("should not show warnings for valid HTTPS URL with ASCII", () => { + render( + , + ); + expect(screen.queryByTestId("url-warnings")).not.toBeInTheDocument(); + }); + + it("should copy URL to clipboard when Copy URL is clicked", async () => { + render( + , + ); + fireEvent.click(screen.getByTestId("copy-url-button")); + await screen.findByText("Copied!"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "https://example.com/complete", + ); + }); +}); diff --git a/client/src/components/__tests__/HistoryAndNotifications.test.tsx b/web/src/components/__tests__/HistoryAndNotifications.test.tsx similarity index 98% rename from client/src/components/__tests__/HistoryAndNotifications.test.tsx rename to web/src/components/__tests__/HistoryAndNotifications.test.tsx index 21f36a31b..e5d6c1da9 100644 --- a/client/src/components/__tests__/HistoryAndNotifications.test.tsx +++ b/web/src/components/__tests__/HistoryAndNotifications.test.tsx @@ -1,15 +1,14 @@ import { render, screen, fireEvent, within } from "@testing-library/react"; import { useState } from "react"; -import { describe, it, expect, jest } from "@jest/globals"; import HistoryAndNotifications from "../HistoryAndNotifications"; import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; // Mock JsonView component -jest.mock("../JsonView", () => { - return function JsonView({ data }: { data: string }) { +vi.mock("../JsonView", () => ({ + default: function JsonView({ data }: { data: string }) { return
{data}
; - }; -}); + }, +})); describe("HistoryAndNotifications", () => { const mockRequestHistory = [ diff --git a/client/src/components/__tests__/ListPane.test.tsx b/web/src/components/__tests__/ListPane.test.tsx similarity index 94% rename from client/src/components/__tests__/ListPane.test.tsx rename to web/src/components/__tests__/ListPane.test.tsx index e930a52a2..94535557c 100644 --- a/client/src/components/__tests__/ListPane.test.tsx +++ b/web/src/components/__tests__/ListPane.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { describe, it, beforeEach, jest } from "@jest/globals"; import ListPane from "../ListPane"; describe("ListPane", () => { @@ -12,8 +11,9 @@ describe("ListPane", () => { const defaultProps = { items: mockItems, - listItems: jest.fn(), - setSelectedItem: jest.fn(), + listItems: vi.fn(), + clearItems: vi.fn(), + setSelectedItem: vi.fn(), renderItem: (item: (typeof mockItems)[0]) =>
{item.name}
, title: "List tools", buttonText: "Load Tools", @@ -24,7 +24,7 @@ describe("ListPane", () => { }; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe("Rendering", () => { @@ -35,13 +35,6 @@ describe("ListPane", () => { expect( screen.getByRole("button", { name: "Load Tools" }), ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Clear" }), - ).not.toBeInTheDocument(); - }); - - it("should render Clear button when clearItems prop is provided", () => { - renderListPane({ clearItems: jest.fn() }); expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); diff --git a/client/src/components/__tests__/MetadataTab.test.tsx b/web/src/components/__tests__/MetadataTab.test.tsx similarity index 96% rename from client/src/components/__tests__/MetadataTab.test.tsx rename to web/src/components/__tests__/MetadataTab.test.tsx index f22e102ff..96f10e7b5 100644 --- a/client/src/components/__tests__/MetadataTab.test.tsx +++ b/web/src/components/__tests__/MetadataTab.test.tsx @@ -11,7 +11,7 @@ import { describe("MetadataTab", () => { const defaultProps = { metadata: {}, - onMetadataChange: jest.fn(), + onMetadataChange: vi.fn(), }; const renderMetadataTab = (props = {}) => { @@ -23,7 +23,7 @@ describe("MetadataTab", () => { }; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe("Initial Rendering", () => { @@ -170,7 +170,7 @@ describe("MetadataTab", () => { }); it("should remove entry when remove button is clicked", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "value1", key2: "value2" }, onMetadataChange, @@ -183,7 +183,7 @@ describe("MetadataTab", () => { }); it("should remove correct entry when multiple entries exist", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { FIRST: "first-value", @@ -203,7 +203,7 @@ describe("MetadataTab", () => { }); it("should show empty state message after removing all entries", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "value1" }, onMetadataChange, @@ -218,7 +218,7 @@ describe("MetadataTab", () => { describe("Editing Entries", () => { it("should update key when key input is changed", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { oldKey: "value1" }, onMetadataChange, @@ -231,7 +231,7 @@ describe("MetadataTab", () => { }); it("should update value when value input is changed", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "oldValue" }, onMetadataChange, @@ -244,7 +244,7 @@ describe("MetadataTab", () => { }); it("should handle editing multiple entries independently", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "value1", @@ -287,7 +287,7 @@ describe("MetadataTab", () => { `( "should display an error for $description", ({ value, message, shouldDisableValue }) => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -309,7 +309,7 @@ describe("MetadataTab", () => { describe("Data Validation and Trimming", () => { it("should trim whitespace from keys and values", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -327,7 +327,7 @@ describe("MetadataTab", () => { }); it("should exclude entries with empty keys or values after trimming", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -351,7 +351,7 @@ describe("MetadataTab", () => { }); it("should exclude entries with whitespace-only keys or values", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -375,7 +375,7 @@ describe("MetadataTab", () => { }); it("should handle mixed valid and invalid entries", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -451,7 +451,7 @@ describe("MetadataTab", () => { describe("Edge Cases", () => { it("should flag invalid names that contain unsupported characters", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -472,7 +472,7 @@ describe("MetadataTab", () => { }); it("should reject unicode names that do not start with an alphanumeric character", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -489,7 +489,7 @@ describe("MetadataTab", () => { }); it("should handle very long keys and values", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -510,7 +510,7 @@ describe("MetadataTab", () => { }); it("should handle duplicate keys by keeping the last one", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ onMetadataChange }); const addButton = screen.getByRole("button", { name: /add entry/i }); @@ -536,7 +536,7 @@ describe("MetadataTab", () => { describe("Integration with Parent Component", () => { it("should not call onMetadataChange when component mounts with existing data", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "value1" }, onMetadataChange, @@ -546,7 +546,7 @@ describe("MetadataTab", () => { }); it("should call onMetadataChange only when user makes changes", () => { - const onMetadataChange = jest.fn(); + const onMetadataChange = vi.fn(); renderMetadataTab({ metadata: { key1: "value1" }, onMetadataChange, @@ -577,7 +577,7 @@ describe("MetadataTab", () => { , ); diff --git a/client/src/components/__tests__/ResourcesTab.test.tsx b/web/src/components/__tests__/ResourcesTab.test.tsx similarity index 91% rename from client/src/components/__tests__/ResourcesTab.test.tsx rename to web/src/components/__tests__/ResourcesTab.test.tsx index 355fa4d63..a82fe2802 100644 --- a/client/src/components/__tests__/ResourcesTab.test.tsx +++ b/web/src/components/__tests__/ResourcesTab.test.tsx @@ -1,25 +1,25 @@ import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Tabs } from "../ui/tabs"; +import { Tabs } from "@/components/ui/tabs"; import ResourcesTab from "../ResourcesTab"; import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js"; // Mock the hooks and components -jest.mock("@/lib/hooks/useCompletionState", () => ({ +vi.mock("@/lib/hooks/useCompletionState", () => ({ useCompletionState: () => ({ completions: {}, - clearCompletions: jest.fn(), - requestCompletions: jest.fn(), + clearCompletions: vi.fn(), + requestCompletions: vi.fn(), }), })); -jest.mock("../JsonView", () => { - return function MockJsonView({ data }: { data: string }) { +vi.mock("../JsonView", () => ({ + default: function MockJsonView({ data }: { data: string }) { return
{data}
; - }; -}); + }, +})); -jest.mock("@/components/ui/combobox", () => ({ +vi.mock("@/components/ui/combobox", () => ({ Combobox: ({ id, value, @@ -41,7 +41,7 @@ jest.mock("@/components/ui/combobox", () => ({ ), })); -jest.mock("@/components/ui/label", () => ({ +vi.mock("@/components/ui/label", () => ({ Label: ({ htmlFor, children, @@ -55,7 +55,7 @@ jest.mock("@/components/ui/label", () => ({ ), })); -jest.mock("@/components/ui/button", () => ({ +vi.mock("@/components/ui/button", () => ({ Button: ({ children, onClick, @@ -79,15 +79,15 @@ jest.mock("@/components/ui/button", () => ({ })); describe("ResourcesTab - Template Query Parameters", () => { - const mockListResources = jest.fn(); - const mockClearResources = jest.fn(); - const mockListResourceTemplates = jest.fn(); - const mockClearResourceTemplates = jest.fn(); - const mockReadResource = jest.fn(); - const mockSetSelectedResource = jest.fn(); - const mockHandleCompletion = jest.fn(); - const mockSubscribeToResource = jest.fn(); - const mockUnsubscribeFromResource = jest.fn(); + const mockListResources = vi.fn(); + const mockClearResources = vi.fn(); + const mockListResourceTemplates = vi.fn(); + const mockClearResourceTemplates = vi.fn(); + const mockReadResource = vi.fn(); + const mockSetSelectedResource = vi.fn(); + const mockHandleCompletion = vi.fn(); + const mockSubscribeToResource = vi.fn(); + const mockUnsubscribeFromResource = vi.fn(); const mockResourceTemplate: ResourceTemplate = { name: "Users API", @@ -131,7 +131,7 @@ describe("ResourcesTab - Template Query Parameters", () => { ); beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it("should parse and display template variables from URI template", () => { diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/web/src/components/__tests__/Sidebar.test.tsx similarity index 89% rename from client/src/components/__tests__/Sidebar.test.tsx rename to web/src/components/__tests__/Sidebar.test.tsx index 03e898ca9..26cf98380 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/web/src/components/__tests__/Sidebar.test.tsx @@ -1,27 +1,26 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { describe, it, beforeEach, jest } from "@jest/globals"; import Sidebar from "../Sidebar"; import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants"; import { InspectorConfig } from "@/lib/configurationTypes"; import { TooltipProvider } from "@/components/ui/tooltip"; // Mock theme hook -jest.mock("../../lib/hooks/useTheme", () => ({ +vi.mock("../../lib/hooks/useTheme", () => ({ __esModule: true, - default: () => ["light", jest.fn()], + default: () => ["light", vi.fn()], })); // Mock toast hook -const mockToast = jest.fn(); -jest.mock("@/lib/hooks/useToast", () => ({ +const mockToast = vi.fn(); +vi.mock("@/lib/hooks/useToast", () => ({ useToast: () => ({ toast: mockToast, }), })); // Mock navigator clipboard -const mockClipboardWrite = jest.fn(() => Promise.resolve()); +const mockClipboardWrite = vi.fn(() => Promise.resolve()); Object.defineProperty(navigator, "clipboard", { value: { writeText: mockClipboardWrite, @@ -29,40 +28,42 @@ Object.defineProperty(navigator, "clipboard", { }); // Setup fake timers -jest.useFakeTimers(); +vi.useFakeTimers(); describe("Sidebar", () => { const defaultProps = { connectionStatus: "disconnected" as const, transportType: "stdio" as const, - setTransportType: jest.fn(), + setTransportType: vi.fn(), command: "", - setCommand: jest.fn(), + setCommand: vi.fn(), args: "", - setArgs: jest.fn(), + setArgs: vi.fn(), + cwd: "", + setCwd: vi.fn(), sseUrl: "", - setSseUrl: jest.fn(), + setSseUrl: vi.fn(), oauthClientId: "", - setOauthClientId: jest.fn(), + setOauthClientId: vi.fn(), oauthClientSecret: "", - setOauthClientSecret: jest.fn(), + setOauthClientSecret: vi.fn(), + oauthClientMetadataUrl: "", + setOauthClientMetadataUrl: vi.fn(), oauthScope: "", - setOauthScope: jest.fn(), + setOauthScope: vi.fn(), env: {}, - setEnv: jest.fn(), + setEnv: vi.fn(), customHeaders: [], - setCustomHeaders: jest.fn(), - onConnect: jest.fn(), - onDisconnect: jest.fn(), + setCustomHeaders: vi.fn(), + onConnect: vi.fn(), + onDisconnect: vi.fn(), stdErrNotifications: [], - clearStdErrNotifications: jest.fn(), + clearStdErrNotifications: vi.fn(), logLevel: "info" as const, - sendLogLevelRequest: jest.fn(), + sendLogLevelRequest: vi.fn(), loggingSupported: true, config: DEFAULT_INSPECTOR_CONFIG, - setConfig: jest.fn(), - connectionType: "proxy" as const, - setConnectionType: jest.fn(), + setConfig: vi.fn(), }; const renderSidebar = (props = {}) => { @@ -74,13 +75,13 @@ describe("Sidebar", () => { }; beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); + vi.clearAllMocks(); + vi.clearAllTimers(); }); describe("Command and arguments", () => { it("should trim whitespace from command input on blur", () => { - const setCommand = jest.fn(); + const setCommand = vi.fn(); renderSidebar({ command: " node server.js ", setCommand }); const commandInput = screen.getByLabelText("Command"); @@ -90,7 +91,7 @@ describe("Sidebar", () => { }); it("should handle whitespace-only command input on blur", () => { - const setCommand = jest.fn(); + const setCommand = vi.fn(); renderSidebar({ command: " ", setCommand }); const commandInput = screen.getByLabelText("Command"); @@ -100,7 +101,7 @@ describe("Sidebar", () => { }); it("should not affect command without surrounding whitespace", () => { - const setCommand = jest.fn(); + const setCommand = vi.fn(); renderSidebar({ command: "node", setCommand }); const commandInput = screen.getByLabelText("Command"); @@ -118,7 +119,7 @@ describe("Sidebar", () => { describe("Basic Operations", () => { it("should add a new environment variable", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); renderSidebar({ env: {}, setEnv }); openEnvVarsSection(); @@ -130,7 +131,7 @@ describe("Sidebar", () => { }); it("should remove an environment variable", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -143,7 +144,7 @@ describe("Sidebar", () => { }); it("should update environment variable value", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -175,7 +176,7 @@ describe("Sidebar", () => { describe("Key Editing", () => { it("should maintain order when editing first key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { FIRST_KEY: "first_value", SECOND_KEY: "second_value", @@ -196,7 +197,7 @@ describe("Sidebar", () => { }); it("should maintain order when editing middle key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { FIRST_KEY: "first_value", SECOND_KEY: "second_value", @@ -219,7 +220,7 @@ describe("Sidebar", () => { }); it("should maintain order when editing last key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { FIRST_KEY: "first_value", SECOND_KEY: "second_value", @@ -240,7 +241,7 @@ describe("Sidebar", () => { }); it("should maintain order during key editing", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { KEY1: "value1", KEY2: "value2", @@ -271,7 +272,7 @@ describe("Sidebar", () => { describe("Multiple Operations", () => { it("should maintain state after multiple key edits", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { FIRST_KEY: "first_value", SECOND_KEY: "second_value", @@ -341,7 +342,7 @@ describe("Sidebar", () => { describe("Edge Cases", () => { it("should handle empty key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -354,7 +355,7 @@ describe("Sidebar", () => { }); it("should handle special characters in key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -367,7 +368,7 @@ describe("Sidebar", () => { }); it("should handle unicode characters in key", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -380,7 +381,7 @@ describe("Sidebar", () => { }); it("should handle a very long key name", () => { - const setEnv = jest.fn(); + const setEnv = vi.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -436,7 +437,7 @@ describe("Sidebar", () => { await act(async () => { const { serverEntry } = getCopyButtons(); fireEvent.click(serverEntry); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -472,7 +473,7 @@ describe("Sidebar", () => { await act(async () => { const { serversFile } = getCopyButtons(); fireEvent.click(serversFile); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -499,7 +500,7 @@ describe("Sidebar", () => { await act(async () => { const { serverEntry } = getCopyButtons(); fireEvent.click(serverEntry); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -527,7 +528,7 @@ describe("Sidebar", () => { await act(async () => { const { serversFile } = getCopyButtons(); fireEvent.click(serversFile); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -554,7 +555,7 @@ describe("Sidebar", () => { await act(async () => { const { serverEntry } = getCopyButtons(); fireEvent.click(serverEntry); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -582,7 +583,7 @@ describe("Sidebar", () => { await act(async () => { const { serversFile } = getCopyButtons(); fireEvent.click(serversFile); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -615,7 +616,7 @@ describe("Sidebar", () => { await act(async () => { const { serverEntry } = getCopyButtons(); fireEvent.click(serverEntry); - jest.runAllTimers(); + vi.runAllTimers(); }); expect(mockClipboardWrite).toHaveBeenCalledTimes(1); @@ -639,7 +640,7 @@ describe("Sidebar", () => { }; it("should update bearer token via custom headers", async () => { - const setCustomHeaders = jest.fn(); + const setCustomHeaders = vi.fn(); renderSidebar({ customHeaders: [], setCustomHeaders, @@ -663,7 +664,7 @@ describe("Sidebar", () => { }); it("should update header name via custom headers", () => { - const setCustomHeaders = jest.fn(); + const setCustomHeaders = vi.fn(); renderSidebar({ customHeaders: [ { @@ -691,7 +692,7 @@ describe("Sidebar", () => { }); it("should clear bearer token via custom headers", () => { - const setCustomHeaders = jest.fn(); + const setCustomHeaders = vi.fn(); renderSidebar({ customHeaders: [ { @@ -856,7 +857,7 @@ describe("Sidebar", () => { }); it("should allow editing existing headers", () => { - const setCustomHeaders = jest.fn(); + const setCustomHeaders = vi.fn(); renderSidebar({ customHeaders: [ { @@ -892,6 +893,69 @@ describe("Sidebar", () => { }); }); + describe("OAuth configuration", () => { + const openAuthSection = () => { + const button = screen.getByTestId("auth-button"); + fireEvent.click(button); + }; + + it("should call setOauthClientId when Client ID input changes", () => { + const setOauthClientId = vi.fn(); + renderSidebar({ + transportType: "sse", + oauthClientId: "", + setOauthClientId, + }); + + openAuthSection(); + + const clientIdInput = screen.getByTestId("oauth-client-id-input"); + fireEvent.change(clientIdInput, { target: { value: "my-client-id" } }); + + expect(setOauthClientId).toHaveBeenCalledWith("my-client-id"); + }); + + it("should call setOauthClientSecret when Client Secret input changes", () => { + const setOauthClientSecret = vi.fn(); + renderSidebar({ + transportType: "sse", + oauthClientSecret: "", + setOauthClientSecret, + }); + + openAuthSection(); + + const secretInput = screen.getByTestId("oauth-client-secret-input"); + fireEvent.change(secretInput, { target: { value: "my-secret" } }); + + expect(setOauthClientSecret).toHaveBeenCalledWith("my-secret"); + }); + + it("should call setOauthClientMetadataUrl when Client Metadata URL input changes", () => { + const setOauthClientMetadataUrl = vi.fn(); + renderSidebar({ + transportType: "sse", + oauthClientMetadataUrl: "", + setOauthClientMetadataUrl, + }); + + openAuthSection(); + + const metadataUrlInput = screen.getByTestId( + "oauth-client-metadata-url-input", + ); + fireEvent.change(metadataUrlInput, { + target: { + value: "https://example.com/.well-known/oauth/client-metadata.json", + }, + }); + + expect(setOauthClientMetadataUrl).toHaveBeenCalledWith( + "https://example.com/.well-known/oauth/client-metadata.json", + ); + }); + }); + describe("Configuration", () => { const openConfigSection = () => { const button = screen.getByTestId("config-button"); @@ -899,7 +963,7 @@ describe("Sidebar", () => { }; it("should update MCP server request timeout", () => { - const setConfig = jest.fn(); + const setConfig = vi.fn(); renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); openConfigSection(); @@ -922,34 +986,8 @@ describe("Sidebar", () => { ); }); - it("should update MCP server proxy address", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const proxyAddressInput = screen.getByTestId( - "MCP_PROXY_FULL_ADDRESS-input", - ); - fireEvent.change(proxyAddressInput, { - target: { value: "http://localhost:8080" }, - }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_PROXY_FULL_ADDRESS: { - label: "Inspector Proxy Address", - description: - "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", - value: "http://localhost:8080", - is_session_item: false, - }, - }), - ); - }); - it("should update max total timeout", () => { - const setConfig = jest.fn(); + const setConfig = vi.fn(); renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); openConfigSection(); @@ -975,7 +1013,7 @@ describe("Sidebar", () => { }); it("should handle invalid timeout values entered by user", () => { - const setConfig = jest.fn(); + const setConfig = vi.fn(); renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); openConfigSection(); @@ -999,7 +1037,7 @@ describe("Sidebar", () => { }); it("should maintain configuration state after multiple updates", () => { - const setConfig = jest.fn(); + const setConfig = vi.fn(); const { rerender } = renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig, diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/web/src/components/__tests__/ToolsTab.test.tsx similarity index 82% rename from client/src/components/__tests__/ToolsTab.test.tsx rename to web/src/components/__tests__/ToolsTab.test.tsx index cb9ebf4ef..16b59e0ed 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/web/src/components/__tests__/ToolsTab.test.tsx @@ -1,16 +1,15 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { describe, it, jest, beforeEach } from "@jest/globals"; import ToolsTab from "../ToolsTab"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { Tabs } from "../ui/tabs"; -import { cacheToolOutputSchemas } from "../../utils/schemaUtils"; +import { Tabs } from "@/components/ui/tabs"; +import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; import { within } from "@testing-library/react"; import { META_NAME_RULES_MESSAGE, META_PREFIX_RULES_MESSAGE, RESERVED_NAMESPACE_MESSAGE, -} from "../../utils/metaUtils"; +} from "@/utils/metaUtils"; describe("ToolsTab", () => { beforeEach(() => { @@ -63,16 +62,18 @@ describe("ToolsTab", () => { const defaultProps = { tools: mockTools, - listTools: jest.fn(), - clearTools: jest.fn(), - callTool: jest.fn(async () => {}), + listTools: vi.fn(), + clearTools: vi.fn(), + callTool: vi.fn(async () => {}), selectedTool: null, - setSelectedTool: jest.fn(), + setSelectedTool: vi.fn(), toolResult: null, + isPollingTask: false, + serverSupportsTaskToolCalls: false, nextCursor: "", error: null, resourceContent: {}, - onReadResource: jest.fn(), + onReadResource: vi.fn(), }; const renderToolsTab = (props = {}) => { @@ -160,8 +161,156 @@ describe("ToolsTab", () => { ); }); + describe("Run as task", () => { + const toolWithOptionalTask: Tool = { + ...mockTools[0], + execution: { taskSupport: "optional" }, + }; + const toolWithForbiddenTask: Tool = { + ...mockTools[0], + name: "tool-forbidden", + execution: { taskSupport: "forbidden" }, + }; + const toolWithRequiredTask: Tool = { + ...mockTools[0], + name: "tool-required", + execution: { taskSupport: "required" }, + }; + + it("should not show Run as task switch when server does not support task tool calls", () => { + renderToolsTab({ + selectedTool: mockTools[0], + serverSupportsTaskToolCalls: false, + }); + expect(screen.queryByRole("switch", { name: /run as task/i })).toBeNull(); + }); + + it("should show Run as task control when server supports task tool calls and a tool is selected", () => { + renderToolsTab({ + selectedTool: toolWithOptionalTask, + serverSupportsTaskToolCalls: true, + }); + expect( + screen.getByRole("switch", { name: /run as task/i }), + ).toBeInTheDocument(); + }); + + it("should show switch unchecked and enabled when tool taskSupport is optional", async () => { + renderToolsTab({ + selectedTool: toolWithOptionalTask, + serverSupportsTaskToolCalls: true, + }); + const runAsTaskSwitch = screen.getByRole("switch", { + name: /run as task/i, + }); + expect(runAsTaskSwitch).not.toBeChecked(); + expect(runAsTaskSwitch).not.toBeDisabled(); + }); + + it("should show switch off and disabled when tool taskSupport is forbidden", () => { + renderToolsTab({ + selectedTool: toolWithForbiddenTask, + serverSupportsTaskToolCalls: true, + }); + const runAsTaskSwitch = screen.getByRole("switch", { + name: /run as task/i, + }); + expect(runAsTaskSwitch).not.toBeChecked(); + expect(runAsTaskSwitch).toBeDisabled(); + }); + + it("should show switch on and disabled when tool taskSupport is required", () => { + renderToolsTab({ + selectedTool: toolWithRequiredTask, + serverSupportsTaskToolCalls: true, + }); + const runAsTaskSwitch = screen.getByRole("switch", { + name: /run as task/i, + }); + expect(runAsTaskSwitch).toBeChecked(); + expect(runAsTaskSwitch).toBeDisabled(); + }); + + it("should call callTool with runAsTask true when optional and switch is on and Run Tool clicked", async () => { + const mockCallTool = vi.fn(async () => {}); + renderToolsTab({ + selectedTool: toolWithOptionalTask, + serverSupportsTaskToolCalls: true, + callTool: mockCallTool, + }); + const runAsTaskSwitch = screen.getByRole("switch", { + name: /run as task/i, + }); + await act(async () => { + fireEvent.click(runAsTaskSwitch); + }); + expect(runAsTaskSwitch).toBeChecked(); + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + expect(mockCallTool).toHaveBeenCalledWith( + toolWithOptionalTask.name, + expect.any(Object), + undefined, + true, + ); + }); + + it("should call callTool with runAsTask true when taskSupport is required without user checking checkbox", async () => { + const mockCallTool = vi.fn(async () => {}); + renderToolsTab({ + selectedTool: toolWithRequiredTask, + serverSupportsTaskToolCalls: true, + callTool: mockCallTool, + }); + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + expect(mockCallTool).toHaveBeenCalledWith( + toolWithRequiredTask.name, + expect.any(Object), + undefined, + true, + ); + }); + + it("should call callTool with runAsTask false when taskSupport is forbidden", async () => { + const mockCallTool = vi.fn(async () => {}); + renderToolsTab({ + selectedTool: toolWithForbiddenTask, + serverSupportsTaskToolCalls: true, + callTool: mockCallTool, + }); + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + expect(mockCallTool).toHaveBeenCalledWith( + toolWithForbiddenTask.name, + expect.any(Object), + undefined, + false, + ); + }); + + it("should show Polling Task... and disable button when isPollingTask is true", () => { + renderToolsTab({ + selectedTool: mockTools[0], + serverSupportsTaskToolCalls: true, + isPollingTask: true, + }); + const runButton = screen.getByRole("button", { + name: /polling task/i, + }); + expect(runButton).toBeInTheDocument(); + expect(runButton).toBeDisabled(); + }); + }); + it("should allow specifying null value", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); const toolWithNullableField = mockTools[3]; renderToolsTab({ @@ -198,7 +347,7 @@ describe("ToolsTab", () => { }); it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); const toolWithNullableBoolean: Tool = { name: "testTool", description: "Tool with nullable boolean", @@ -313,7 +462,7 @@ describe("ToolsTab", () => { }); // Mock callTool to return our promise - const mockCallTool = jest.fn().mockReturnValue(mockPromise); + const mockCallTool = vi.fn().mockReturnValue(mockPromise); renderToolsTab({ selectedTool: mockTools[0], @@ -589,8 +738,13 @@ describe("ToolsTab", () => { toolResult: noMatchResult, }); - // Should render without crashing - the validation logic has been updated expect(screen.getAllByText("weatherTool")).toHaveLength(2); + // No compatibility warning when content is not a JSON copy of structuredContent (PR #1098) + expect( + screen.queryByText( + /structured content matches|no text blocks|no.*matches/i, + ), + ).not.toBeInTheDocument(); }); it("should reject when no text blocks are present", () => { @@ -605,8 +759,13 @@ describe("ToolsTab", () => { toolResult: noTextBlocksResult, }); - // Should render without crashing - the validation logic has been updated expect(screen.getAllByText("weatherTool")).toHaveLength(2); + // No compatibility message when no text blocks (PR #1098) + expect( + screen.queryByText( + /structured content matches|no text blocks|no.*matches/i, + ), + ).not.toBeInTheDocument(); }); it("should not show compatibility check when tool has no output schema", () => { @@ -627,11 +786,37 @@ describe("ToolsTab", () => { ), ).not.toBeInTheDocument(); }); + + it("should not show compatibility warning when content is human-readable (not JSON copy of structuredContent)", () => { + const humanReadableResult = { + content: [ + { + type: "text", + text: "The temperature in Seattle is 25 degrees C", + }, + ], + structuredContent: { temperature: 25, unit: "C", city: "Seattle" }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: humanReadableResult, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + // No yellow warning; MCP spec allows content to be human-readable (PR #1098) + expect( + screen.queryByText( + /no text blocks? to match|no text block matches structured/i, + ), + ).not.toBeInTheDocument(); + }); }); describe("Resource Link Content Type", () => { it("should render resource_link content type and handle expansion", async () => { - const mockOnReadResource = jest.fn(); + const mockOnReadResource = vi.fn(); const resourceContent = { "test://static/resource/1": JSON.stringify({ contents: [ @@ -791,7 +976,7 @@ describe("ToolsTab", () => { describe("Metadata submission", () => { it("should send metadata values when provided", async () => { - const callToolMock = jest.fn(async () => {}); + const callToolMock = vi.fn(async () => {}); renderToolsTab({ selectedTool: mockTools[0], callTool: callToolMock }); @@ -896,7 +1081,7 @@ describe("ToolsTab", () => { beforeEach(() => { // Mock scrollIntoView for Radix UI Select - Element.prototype.scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = vi.fn(); }); it("should render enum parameter as dropdown", () => { @@ -957,7 +1142,7 @@ describe("ToolsTab", () => { }; it("should prevent tool execution when JSON validation fails", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); renderToolsTab({ tools: [toolWithJsonParams], selectedTool: toolWithJsonParams, @@ -985,7 +1170,7 @@ describe("ToolsTab", () => { }); it("should allow tool execution when JSON validation passes", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); renderToolsTab({ tools: [toolWithJsonParams], selectedTool: toolWithJsonParams, @@ -1020,7 +1205,7 @@ describe("ToolsTab", () => { }); it("should handle mixed valid and invalid JSON parameters", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); renderToolsTab({ tools: [toolWithJsonParams], selectedTool: toolWithJsonParams, @@ -1048,7 +1233,7 @@ describe("ToolsTab", () => { }); it("should work with tools that have no JSON parameters", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); const simpleToolWithStringParam: Tool = { name: "simpleTool", description: "Tool with simple parameters", @@ -1093,7 +1278,7 @@ describe("ToolsTab", () => { }); it("should handle empty JSON parameters correctly", async () => { - const mockCallTool = jest.fn(); + const mockCallTool = vi.fn(); renderToolsTab({ tools: [toolWithJsonParams], selectedTool: toolWithJsonParams, diff --git a/client/src/components/__tests__/samplingRequest.test.tsx b/web/src/components/__tests__/samplingRequest.test.tsx similarity index 95% rename from client/src/components/__tests__/samplingRequest.test.tsx rename to web/src/components/__tests__/samplingRequest.test.tsx index 80d87d986..24aa1e981 100644 --- a/client/src/components/__tests__/samplingRequest.test.tsx +++ b/web/src/components/__tests__/samplingRequest.test.tsx @@ -24,11 +24,11 @@ const mockRequest: PendingRequest = { }; describe("Form to handle sampling response", () => { - const mockOnApprove = jest.fn(); - const mockOnReject = jest.fn(); + const mockOnApprove = vi.fn(); + const mockOnReject = vi.fn(); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it("should call onApprove with correct text content when Approve button is clicked", () => { diff --git a/web/src/components/__tests__/samplingTab.test.tsx b/web/src/components/__tests__/samplingTab.test.tsx new file mode 100644 index 000000000..22224cdd5 --- /dev/null +++ b/web/src/components/__tests__/samplingTab.test.tsx @@ -0,0 +1,94 @@ +/** + * Unit tests for SamplingTab: when pending requests are passed in, + * they are rendered and Approve/Reject call the correct callbacks. + * Mirrors the behavior asserted in client's App.samplingNavigation.test.tsx + * and shared's inspectorClient.test.ts (sampling event + respond/reject). + */ +import { fireEvent, render, screen } from "@testing-library/react"; +import SamplingTab from "../SamplingTab"; +import { Tabs } from "../ui/tabs"; +import type { + CreateMessageRequest, + CreateMessageResult, +} from "@modelcontextprotocol/sdk/types.js"; + +function renderSamplingTab(props: React.ComponentProps) { + return render( + + + , + ); +} + +vi.mock("../SamplingRequest", () => ({ + default: ({ + request, + onApprove, + onReject, + }: { + request: { id: number }; + onApprove: (id: number, result: CreateMessageResult) => void; + onReject: (id: number) => void; + }) => ( +
+ request-{request.id} + + +
+ ), +})); + +const sampleRequest: CreateMessageRequest = { + method: "sampling/createMessage", + params: { messages: [], maxTokens: 1 }, +}; + +describe("SamplingTab", () => { + it("renders pending requests and Approve calls onApprove with id and result", () => { + const onApprove = vi.fn(); + const onReject = vi.fn(); + renderSamplingTab({ + pendingRequests: [{ id: 1, request: sampleRequest }], + onApprove, + onReject, + }); + + expect(screen.getByTestId("sampling-request")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Approve/i })); + + expect(onApprove).toHaveBeenCalledWith( + 1, + expect.objectContaining({ model: "m", role: "assistant" }), + ); + expect(onReject).not.toHaveBeenCalled(); + }); + + it("Reject calls onReject with id", () => { + const onApprove = vi.fn(); + const onReject = vi.fn(); + renderSamplingTab({ + pendingRequests: [{ id: 2, request: sampleRequest }], + onApprove, + onReject, + }); + + fireEvent.click(screen.getByRole("button", { name: /Reject/i })); + + expect(onReject).toHaveBeenCalledWith(2); + expect(onApprove).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx similarity index 100% rename from client/src/components/ui/alert.tsx rename to web/src/components/ui/alert.tsx diff --git a/client/src/components/ui/button.tsx b/web/src/components/ui/button.tsx similarity index 100% rename from client/src/components/ui/button.tsx rename to web/src/components/ui/button.tsx diff --git a/client/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx similarity index 100% rename from client/src/components/ui/checkbox.tsx rename to web/src/components/ui/checkbox.tsx diff --git a/client/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx similarity index 100% rename from client/src/components/ui/combobox.tsx rename to web/src/components/ui/combobox.tsx diff --git a/client/src/components/ui/command.tsx b/web/src/components/ui/command.tsx similarity index 100% rename from client/src/components/ui/command.tsx rename to web/src/components/ui/command.tsx diff --git a/client/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx similarity index 100% rename from client/src/components/ui/dialog.tsx rename to web/src/components/ui/dialog.tsx diff --git a/client/src/components/ui/input.tsx b/web/src/components/ui/input.tsx similarity index 100% rename from client/src/components/ui/input.tsx rename to web/src/components/ui/input.tsx diff --git a/client/src/components/ui/label.tsx b/web/src/components/ui/label.tsx similarity index 100% rename from client/src/components/ui/label.tsx rename to web/src/components/ui/label.tsx diff --git a/client/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx similarity index 100% rename from client/src/components/ui/popover.tsx rename to web/src/components/ui/popover.tsx diff --git a/client/src/components/ui/select.tsx b/web/src/components/ui/select.tsx similarity index 100% rename from client/src/components/ui/select.tsx rename to web/src/components/ui/select.tsx diff --git a/client/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx similarity index 100% rename from client/src/components/ui/switch.tsx rename to web/src/components/ui/switch.tsx diff --git a/client/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx similarity index 98% rename from client/src/components/ui/tabs.tsx rename to web/src/components/ui/tabs.tsx index d1b685fad..ecc9f95d2 100644 --- a/client/src/components/ui/tabs.tsx +++ b/web/src/components/ui/tabs.tsx @@ -42,7 +42,7 @@ const TabsContent = React.forwardRef< , + customHeaders?: CustomHeaders, + cwd?: string, +): MCPServerConfig { + switch (transportType) { + case "stdio": { + if (!command) { + throw new Error("Command is required for stdio transport"); + } + const config: MCPServerConfig = { + type: "stdio", + command, + }; + if (args?.trim()) { + config.args = args.split(/\s+/).filter((arg) => arg.length > 0); + } + if (env && Object.keys(env).length > 0) { + config.env = env; + } + if (cwd?.trim()) { + config.cwd = cwd.trim(); + } + return config; + } + case "sse": { + if (!sseUrl) { + throw new Error("SSE URL is required for SSE transport"); + } + const headers = customHeaders + ? headersToRecord(customHeaders) + : undefined; + const config: MCPServerConfig = { + type: "sse", + url: sseUrl, + }; + if (headers && Object.keys(headers).length > 0) { + config.headers = headers; + } + return config; + } + case "streamable-http": { + if (!sseUrl) { + throw new Error("Server URL is required for streamable-http transport"); + } + const headers = customHeaders + ? headersToRecord(customHeaders) + : undefined; + const config: MCPServerConfig = { + type: "streamable-http", + url: sseUrl, + }; + if (headers && Object.keys(headers).length > 0) { + config.headers = headers; + } + return config; + } + } +} diff --git a/web/src/lib/adapters/environmentFactory.ts b/web/src/lib/adapters/environmentFactory.ts new file mode 100644 index 000000000..39d0c9dbc --- /dev/null +++ b/web/src/lib/adapters/environmentFactory.ts @@ -0,0 +1,71 @@ +import type { InspectorClientEnvironment } from "@modelcontextprotocol/inspector-core/mcp/index.js"; +import { + createRemoteTransport, + createRemoteFetch, + createRemoteLogger, +} from "@modelcontextprotocol/inspector-core/mcp/remote/index.js"; +import { + BrowserOAuthStorage, + BrowserNavigation, +} from "@modelcontextprotocol/inspector-core/auth/browser/index.js"; +import type { RedirectUrlProvider } from "@modelcontextprotocol/inspector-core/auth/index.js"; + +export type WebEnvironmentResult = { + environment: InspectorClientEnvironment; + logger: InspectorClientEnvironment["logger"]; +}; + +/** + * Creates an InspectorClientEnvironment for the web client. + * This factory provides all the environment-specific implementations needed + * by InspectorClient in a browser environment: + * - Inspector API transport (via Hono API server) + * - Inspector API fetch (for OAuth, bypasses CORS) + * - Inspector API logger (sends logs to server) + * - Browser OAuth storage and navigation + * + * Returns both the environment and the logger so the app can pass the same + * logger to components (e.g. AuthDebugger, OAuthCallback) instead of + * reading it from InspectorClient. + * + * @param authToken - Auth token for authenticating with the Inspector API server + * @param redirectUrlProvider - Provider for OAuth redirect URLs + * @returns Environment and logger for InspectorClient and app use + */ +export function createWebEnvironment( + authToken: string | undefined, + redirectUrlProvider: RedirectUrlProvider, +): WebEnvironmentResult { + const baseUrl = `${window.location.protocol}//${window.location.host}`; + + // Wrap fetch in a function to preserve 'this' context + // Passing window.fetch directly causes "Illegal invocation" error + const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args); + + const logger = createRemoteLogger({ + baseUrl, + authToken, + fetchFn, + }); + + const environment: InspectorClientEnvironment = { + transport: createRemoteTransport({ + baseUrl, + authToken, + fetchFn, + }), + fetch: createRemoteFetch({ + baseUrl, + authToken, + fetchFn, + }), + logger, + oauth: { + storage: new BrowserOAuthStorage(), + navigation: new BrowserNavigation(), + redirectUrlProvider, + }, + }; + + return { environment, logger }; +} diff --git a/client/src/lib/configurationTypes.ts b/web/src/lib/configurationTypes.ts similarity index 77% rename from client/src/lib/configurationTypes.ts rename to web/src/lib/configurationTypes.ts index 60a993564..6adcfa8e1 100644 --- a/client/src/lib/configurationTypes.ts +++ b/web/src/lib/configurationTypes.ts @@ -6,8 +6,8 @@ export type ConfigItem = { }; /** - * Configuration interface for the MCP Inspector, including settings for the MCP Client, - * Proxy Server, and Inspector UI/UX. + * Configuration interface for the MCP Inspector, including settings for the MCP Client + * and Inspector UI/UX. * * Note: Configuration related to which MCP Server to use or any other MCP Server * specific settings are outside the scope of this interface as of now. @@ -32,14 +32,9 @@ export type InspectorConfig = { MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem; /** - * The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577 + * Auth token for authenticating with the Inspector API server. This token is displayed in the console on startup. */ - MCP_PROXY_FULL_ADDRESS: ConfigItem; - - /** - * Session token for authenticating with the MCP Proxy Server. This token is displayed in the proxy server console on startup. - */ - MCP_PROXY_AUTH_TOKEN: ConfigItem; + MCP_INSPECTOR_API_TOKEN: ConfigItem; /** * Default Time-to-Live (TTL) in milliseconds for newly created tasks. diff --git a/client/src/lib/constants.ts b/web/src/lib/constants.ts similarity index 79% rename from client/src/lib/constants.ts rename to web/src/lib/constants.ts index 6cb1a02cc..24ef950a5 100644 --- a/client/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -29,13 +29,7 @@ export const getServerSpecificKey = ( return `[${serverUrl}] ${baseKey}`; }; -export type ConnectionStatus = - | "disconnected" - | "connected" - | "error" - | "error-connecting-to-proxy"; - -export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; +export type ConnectionStatus = "disconnected" | "connected" | "error"; /** * Default configuration for the MCP Inspector, Currently persisted in local_storage in the Browser. @@ -62,17 +56,10 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { value: 60000, is_session_item: false, }, - MCP_PROXY_FULL_ADDRESS: { - label: "Inspector Proxy Address", - description: - "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", - value: "", - is_session_item: false, - }, - MCP_PROXY_AUTH_TOKEN: { - label: "Proxy Session Token", + MCP_INSPECTOR_API_TOKEN: { + label: "API Token", description: - "Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)", + "Auth token for authenticating with the Inspector API server (displayed in console on startup)", value: "", is_session_item: true, }, diff --git a/client/src/lib/hooks/useCompletionState.ts b/web/src/lib/hooks/useCompletionState.ts similarity index 93% rename from client/src/lib/hooks/useCompletionState.ts rename to web/src/lib/hooks/useCompletionState.ts index 26b69a276..84dabb582 100644 --- a/client/src/lib/hooks/useCompletionState.ts +++ b/web/src/lib/hooks/useCompletionState.ts @@ -9,13 +9,12 @@ interface CompletionState { loading: Record; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function debounce PromiseLike>( - func: T, +function debounce( + func: (...args: A) => PromiseLike, wait: number, -): (...args: Parameters) => void { +): (...args: A) => void { let timeout: ReturnType; - return (...args: Parameters) => { + return (...args: A) => { clearTimeout(timeout); timeout = setTimeout(() => { void func(...args); diff --git a/client/src/lib/hooks/useCopy.ts b/web/src/lib/hooks/useCopy.ts similarity index 100% rename from client/src/lib/hooks/useCopy.ts rename to web/src/lib/hooks/useCopy.ts diff --git a/client/src/lib/hooks/useDraggablePane.ts b/web/src/lib/hooks/useDraggablePane.ts similarity index 100% rename from client/src/lib/hooks/useDraggablePane.ts rename to web/src/lib/hooks/useDraggablePane.ts diff --git a/client/src/lib/hooks/useTheme.ts b/web/src/lib/hooks/useTheme.ts similarity index 100% rename from client/src/lib/hooks/useTheme.ts rename to web/src/lib/hooks/useTheme.ts diff --git a/client/src/lib/hooks/useToast.ts b/web/src/lib/hooks/useToast.ts similarity index 100% rename from client/src/lib/hooks/useToast.ts rename to web/src/lib/hooks/useToast.ts diff --git a/client/src/lib/notificationTypes.ts b/web/src/lib/notificationTypes.ts similarity index 100% rename from client/src/lib/notificationTypes.ts rename to web/src/lib/notificationTypes.ts diff --git a/client/src/lib/types/customHeaders.ts b/web/src/lib/types/customHeaders.ts similarity index 100% rename from client/src/lib/types/customHeaders.ts rename to web/src/lib/types/customHeaders.ts diff --git a/client/src/lib/utils.ts b/web/src/lib/utils.ts similarity index 100% rename from client/src/lib/utils.ts rename to web/src/lib/utils.ts diff --git a/client/src/main.tsx b/web/src/main.tsx similarity index 100% rename from client/src/main.tsx rename to web/src/main.tsx diff --git a/web/src/sandbox-controller.test.ts b/web/src/sandbox-controller.test.ts new file mode 100644 index 000000000..3d7cc706a --- /dev/null +++ b/web/src/sandbox-controller.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + resolveSandboxPort, + createSandboxController, +} from "./sandbox-controller.js"; + +describe("resolveSandboxPort", () => { + let origMCP: string | undefined; + let origServer: string | undefined; + + beforeEach(() => { + origMCP = process.env.MCP_SANDBOX_PORT; + origServer = process.env.SERVER_PORT; + delete process.env.MCP_SANDBOX_PORT; + delete process.env.SERVER_PORT; + }); + + afterEach(() => { + if (origMCP !== undefined) process.env.MCP_SANDBOX_PORT = origMCP; + else delete process.env.MCP_SANDBOX_PORT; + if (origServer !== undefined) process.env.SERVER_PORT = origServer; + else delete process.env.SERVER_PORT; + }); + + it("returns MCP_SANDBOX_PORT when set and valid", () => { + process.env.MCP_SANDBOX_PORT = "9123"; + expect(resolveSandboxPort()).toBe(9123); + }); + + it("falls back to SERVER_PORT when MCP_SANDBOX_PORT is unset", () => { + process.env.SERVER_PORT = "6277"; + expect(resolveSandboxPort()).toBe(6277); + }); + + it("falls back to SERVER_PORT when MCP_SANDBOX_PORT is empty string", () => { + process.env.MCP_SANDBOX_PORT = ""; + process.env.SERVER_PORT = "5000"; + expect(resolveSandboxPort()).toBe(5000); + }); + + it("returns 0 (dynamic) when neither is set", () => { + expect(resolveSandboxPort()).toBe(0); + }); + + it("returns 0 when MCP_SANDBOX_PORT is invalid and SERVER_PORT unset", () => { + process.env.MCP_SANDBOX_PORT = "not-a-number"; + expect(resolveSandboxPort()).toBe(0); + }); + + it("prefers MCP_SANDBOX_PORT over SERVER_PORT when both set", () => { + process.env.MCP_SANDBOX_PORT = "1111"; + process.env.SERVER_PORT = "2222"; + expect(resolveSandboxPort()).toBe(1111); + }); +}); + +describe("createSandboxController", () => { + const minimalHtml = "ok"; + + it("start() returns port and url, getUrl() returns URL until close", async () => { + const controller = createSandboxController({ + port: 0, + sandboxHtml: minimalHtml, + host: "127.0.0.1", + }); + const { port, url } = await controller.start(); + expect(port).toBeGreaterThan(0); + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); + expect(controller.getUrl()).toBe(url); + await controller.close(); + expect(controller.getUrl()).toBeNull(); + }); + + it("with port 0 (dynamic): start() uses OS-assigned port", async () => { + const controller = createSandboxController({ + port: 0, + sandboxHtml: minimalHtml, + }); + const { port, url } = await controller.start(); + expect(port).toBeGreaterThan(0); + expect(url).toMatch(/^http:\/\/localhost:\d+\/sandbox$/); + await controller.close(); + }); + + it("close() is idempotent", async () => { + const controller = createSandboxController({ + port: 0, + sandboxHtml: minimalHtml, + }); + await controller.start(); + await controller.close(); + await controller.close(); + }); +}); diff --git a/web/src/sandbox-controller.ts b/web/src/sandbox-controller.ts new file mode 100644 index 000000000..a8c93d78b --- /dev/null +++ b/web/src/sandbox-controller.ts @@ -0,0 +1,106 @@ +/** + * Sandbox server controller: start/close and get URL. + * Used by server.ts (prod) and Vite plugin (dev/test). Same process lifecycle as the main server. + */ + +import { createServer, type Server } from "node:http"; + +export interface SandboxControllerOptions { + /** Port to bind (0 = dynamic). */ + port: number; + /** HTML content to serve for GET /sandbox and /sandbox/ */ + sandboxHtml: string; + /** Host to bind (default localhost). */ + host?: string; +} + +export interface SandboxController { + start(): Promise<{ port: number; url: string }>; + close(): Promise; + getUrl(): string | null; +} + +/** + * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). + */ +export function resolveSandboxPort(): number { + const fromSandbox = process.env.MCP_SANDBOX_PORT; + if (fromSandbox !== undefined && fromSandbox !== "") { + const n = parseInt(fromSandbox, 10); + if (!Number.isNaN(n) && n >= 0) return n; + } + const fromServer = process.env.SERVER_PORT; + if (fromServer !== undefined && fromServer !== "") { + const n = parseInt(fromServer, 10); + if (!Number.isNaN(n) && n >= 0) return n; + } + return 0; +} + +export function createSandboxController( + options: SandboxControllerOptions, +): SandboxController { + const { port, sandboxHtml, host = "localhost" } = options; + let server: Server | null = null; + let sandboxUrl: string | null = null; + + return { + async start(): Promise<{ port: number; url: string }> { + if (server && sandboxUrl) { + const p = parseInt(new URL(sandboxUrl).port, 10); + return { port: p, url: sandboxUrl }; + } + return new Promise((resolve) => { + server = createServer((req, res) => { + if ( + req.method !== "GET" || + (req.url !== "/sandbox" && req.url !== "/sandbox/") + ) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + }); + res.end(sandboxHtml); + }); + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error( + `Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`, + ); + } else { + console.error("Sandbox server error:", err); + } + }); + server.listen(port, host, () => { + const addr = server!.address(); + const actualPort = + typeof addr === "object" && addr !== null && "port" in addr + ? addr.port + : (addr as unknown as number); + sandboxUrl = `http://${host}:${actualPort}/sandbox`; + resolve({ port: actualPort, url: sandboxUrl }); + }); + }); + }, + + async close(): Promise { + if (!server) return; + return new Promise((resolve) => { + server!.close(() => { + server = null; + sandboxUrl = null; + resolve(); + }); + }); + }, + + getUrl(): string | null { + return sandboxUrl; + }, + }; +} diff --git a/web/src/server.ts b/web/src/server.ts new file mode 100644 index 000000000..07374d5b4 --- /dev/null +++ b/web/src/server.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import open from "open"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import pino from "pino"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; +import { + createSandboxController, + resolveSandboxPort, +} from "./sandbox-controller.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// When run as dist/server.js, __dirname is dist/; index and assets live there +const distPath = __dirname; +const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); + +const app = new Hono(); + +const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; +const authToken = dangerouslyOmitAuth + ? "" + : (process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ?? + process.env[LEGACY_AUTH_TOKEN_ENV] ?? + randomBytes(32).toString("hex")); + +const port = parseInt(process.env.CLIENT_PORT || "6274", 10); +const host = process.env.HOST || "localhost"; +const baseUrl = `http://${host}:${port}`; + +let sandboxHtml: string; +try { + sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); +} catch (e) { + sandboxHtml = + "Sandbox not loaded: " + + String((e as Error).message) + + ""; +} + +const sandboxController = createSandboxController({ + port: resolveSandboxPort(), + sandboxHtml, + host, +}); +await sandboxController.start(); + +const { app: apiApp } = createRemoteApp({ + authToken: dangerouslyOmitAuth ? undefined : authToken, + dangerouslyOmitAuth, + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? [baseUrl], + sandboxUrl: sandboxController.getUrl() ?? undefined, + logger: process.env.MCP_LOG_FILE + ? pino( + { level: "info" }, + pino.destination({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + }), + ) + : undefined, +}); + +const SHUTDOWN_TIMEOUT_MS = 10_000; + +async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + + const forceExit = setTimeout(() => { + console.error("Shutdown timeout; forcing exit"); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + try { + await sandboxController.close(); + } catch (err) { + console.error("Sandbox close error:", err); + } + + httpServer.close((err) => { + clearTimeout(forceExit); + if (err) { + console.error("Server close error:", err); + process.exit(1); + } + process.exit(0); + }); +} + +let shuttingDown = false; +process.on("SIGINT", () => { + void shutdown(); +}); +process.on("SIGTERM", () => { + void shutdown(); +}); + +app.use("/api/*", async (c) => { + return apiApp.fetch(c.req.raw); +}); + +app.get("/", async (c) => { + try { + const indexPath = join(distPath, "index.html"); + const html = readFileSync(indexPath, "utf-8"); + return c.html(html); + } catch (error) { + console.error("Error serving index.html:", error); + return c.notFound(); + } +}); + +app.use( + "/*", + serveStatic({ + root: distPath, + rewriteRequestPath: (path) => { + if (!path.includes(".") && !path.startsWith("/api")) { + return "/index.html"; + } + return path; + }, + }), +); + +const httpServer = serve( + { + fetch: app.fetch, + port, + hostname: host, + }, + (info) => { + const baseUrl = `http://${host}:${info.port}`; + const url = + dangerouslyOmitAuth || !authToken + ? baseUrl + : `${baseUrl}?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${authToken}`; + console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); + const sandboxUrl = sandboxController.getUrl(); + if (sandboxUrl) { + console.log(` Sandbox (MCP Apps): ${sandboxUrl}\n`); + } + if (dangerouslyOmitAuth) { + console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n"); + } else { + console.log(` Auth token: ${authToken}\n`); + } + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + console.log("🌐 Opening browser..."); + open(url); + } + }, +); + +httpServer.on("error", (err: Error) => { + if (err.message.includes("EADDRINUSE")) { + console.error( + `āŒ MCP Inspector PORT IS IN USE at http://${host}:${port} āŒ `, + ); + process.exit(1); + } else { + throw err; + } +}); diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/src/utils/__tests__/configUtils.test.ts b/web/src/utils/__tests__/configUtils.test.ts new file mode 100644 index 000000000..36eb2fede --- /dev/null +++ b/web/src/utils/__tests__/configUtils.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + getMCPServerRequestTimeout, + resetRequestTimeoutOnProgress, + getMCPServerRequestMaxTotalTimeout, + getInspectorApiToken, + getMCPTaskTtl, + getInitialTransportType, + getInitialSseUrl, + getInitialCommand, + getInitialArgs, + getConfigOverridesFromQueryParams, + initializeInspectorConfig, + saveInspectorConfig, +} from "../configUtils"; +import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants"; +import type { InspectorConfig } from "@/lib/configurationTypes"; + +const CONFIG_KEY = "test-inspector-config"; + +describe("configUtils", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getMCPServerRequestTimeout", () => { + it("returns timeout value from config", () => { + const config = { ...DEFAULT_INSPECTOR_CONFIG }; + expect(getMCPServerRequestTimeout(config)).toBe(300000); + }); + + it("returns custom value when config is overridden", () => { + const config: InspectorConfig = { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_SERVER_REQUEST_TIMEOUT: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT, + value: 5000, + }, + }; + expect(getMCPServerRequestTimeout(config)).toBe(5000); + }); + }); + + describe("resetRequestTimeoutOnProgress", () => { + it("returns boolean from config", () => { + expect(resetRequestTimeoutOnProgress(DEFAULT_INSPECTOR_CONFIG)).toBe( + true, + ); + }); + }); + + describe("getMCPServerRequestMaxTotalTimeout", () => { + it("returns max total timeout from config", () => { + expect(getMCPServerRequestMaxTotalTimeout(DEFAULT_INSPECTOR_CONFIG)).toBe( + 60000, + ); + }); + }); + + describe("getInspectorApiToken", () => { + it("returns undefined when token is empty", () => { + expect(getInspectorApiToken(DEFAULT_INSPECTOR_CONFIG)).toBeUndefined(); + }); + + it("returns token when configured", () => { + const config: InspectorConfig = { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_INSPECTOR_API_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_INSPECTOR_API_TOKEN, + value: "test-token", + }, + }; + expect(getInspectorApiToken(config)).toBe("test-token"); + }); + }); + + describe("getMCPTaskTtl", () => { + it("returns default task TTL from config", () => { + expect(getMCPTaskTtl(DEFAULT_INSPECTOR_CONFIG)).toBe(60000); + }); + + it("returns custom value when config is overridden", () => { + const config: InspectorConfig = { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_TASK_TTL: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_TASK_TTL, + value: 120000, + }, + }; + expect(getMCPTaskTtl(config)).toBe(120000); + }); + }); + + describe("getInitialTransportType", () => { + it("returns transport from URL search param when present", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/?transport=sse" }, + writable: true, + }); + expect(getInitialTransportType()).toBe("sse"); + }); + + it("returns stdio when no param and localStorage empty", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + expect(getInitialTransportType()).toBe("stdio"); + }); + + it("returns value from localStorage when no URL param", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + localStorage.setItem("lastTransportType", "streamable-http"); + expect(getInitialTransportType()).toBe("streamable-http"); + }); + }); + + describe("getInitialSseUrl", () => { + it("returns serverUrl from URL param when present", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://localhost/?serverUrl=http%3A%2F%2Fexample.com%2Fsse", + }, + writable: true, + }); + expect(getInitialSseUrl()).toBe("http://example.com/sse"); + }); + + it("returns default when no param and localStorage empty", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + expect(getInitialSseUrl()).toBe("http://localhost:3001/sse"); + }); + }); + + describe("getInitialCommand", () => { + it("returns command from URL param when present", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/?serverCommand=my-mcp-server" }, + writable: true, + }); + expect(getInitialCommand()).toBe("my-mcp-server"); + }); + + it("returns default when no param and localStorage empty", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + expect(getInitialCommand()).toBe("mcp-server-everything"); + }); + }); + + describe("getInitialArgs", () => { + it("returns args from URL param when present", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/?serverArgs=--verbose" }, + writable: true, + }); + expect(getInitialArgs()).toBe("--verbose"); + }); + + it("returns empty string when no param and localStorage empty", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + expect(getInitialArgs()).toBe(""); + }); + }); + + describe("getConfigOverridesFromQueryParams", () => { + it("returns empty object when no matching params", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + expect( + getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG), + ).toEqual({}); + }); + + it("coerces number params", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://localhost/?MCP_SERVER_REQUEST_TIMEOUT=10000", + }, + writable: true, + }); + const overrides = getConfigOverridesFromQueryParams( + DEFAULT_INSPECTOR_CONFIG, + ); + expect(overrides.MCP_SERVER_REQUEST_TIMEOUT?.value).toBe(10000); + }); + + it("coerces boolean params", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://localhost/?MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false", + }, + writable: true, + }); + const overrides = getConfigOverridesFromQueryParams( + DEFAULT_INSPECTOR_CONFIG, + ); + expect(overrides.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS?.value).toBe( + false, + ); + }); + }); + + describe("initializeInspectorConfig", () => { + it("returns default config when storage is empty", () => { + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + const config = initializeInspectorConfig(CONFIG_KEY); + expect(config.MCP_SERVER_REQUEST_TIMEOUT.value).toBe(300000); + expect(config.MCP_INSPECTOR_API_TOKEN.value).toBe(""); + }); + + it("merges persisted config from localStorage", () => { + const persisted = { + MCP_SERVER_REQUEST_TIMEOUT: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT, + value: 60000, + }, + }; + localStorage.setItem(CONFIG_KEY, JSON.stringify(persisted)); + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + const config = initializeInspectorConfig(CONFIG_KEY); + expect(config.MCP_SERVER_REQUEST_TIMEOUT.value).toBe(60000); + }); + + it("strips unrecognized keys from saved config", () => { + const persisted = { + MCP_SERVER_REQUEST_TIMEOUT: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT, + value: 1000, + }, + UNKNOWN_KEY: { value: "ignored" }, + }; + localStorage.setItem(CONFIG_KEY, JSON.stringify(persisted)); + Object.defineProperty(window, "location", { + value: { href: "http://localhost/" }, + writable: true, + }); + const config = initializeInspectorConfig(CONFIG_KEY); + expect(config.MCP_SERVER_REQUEST_TIMEOUT.value).toBe(1000); + expect( + (config as Record)["UNKNOWN_KEY"], + ).toBeUndefined(); + }); + }); + + describe("saveInspectorConfig", () => { + it("persists non-session config to localStorage", () => { + const config = { ...DEFAULT_INSPECTOR_CONFIG }; + saveInspectorConfig(CONFIG_KEY, config); + const saved = localStorage.getItem(CONFIG_KEY); + expect(saved).toBeTruthy(); + const parsed = JSON.parse(saved!); + expect(parsed.MCP_SERVER_REQUEST_TIMEOUT).toBeDefined(); + }); + + it("persists session config to sessionStorage", () => { + const config = { ...DEFAULT_INSPECTOR_CONFIG }; + saveInspectorConfig(CONFIG_KEY, config); + const saved = sessionStorage.getItem(`${CONFIG_KEY}_ephemeral`); + expect(saved).toBeTruthy(); + const parsed = JSON.parse(saved!); + expect(parsed.MCP_INSPECTOR_API_TOKEN).toBeDefined(); + }); + }); +}); diff --git a/client/src/utils/__tests__/escapeUnicode.test.ts b/web/src/utils/__tests__/escapeUnicode.test.ts similarity index 100% rename from client/src/utils/__tests__/escapeUnicode.test.ts rename to web/src/utils/__tests__/escapeUnicode.test.ts diff --git a/client/src/utils/__tests__/jsonUtils.test.ts b/web/src/utils/__tests__/jsonUtils.test.ts similarity index 98% rename from client/src/utils/__tests__/jsonUtils.test.ts rename to web/src/utils/__tests__/jsonUtils.test.ts index 42938eb55..b942afbc2 100644 --- a/client/src/utils/__tests__/jsonUtils.test.ts +++ b/web/src/utils/__tests__/jsonUtils.test.ts @@ -222,7 +222,7 @@ describe("updateValueAtPath", () => { // Error handling tests test("returns original value when trying to update a primitive with a path", () => { - const spy = jest.spyOn(console, "error").mockImplementation(); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const result = updateValueAtPath("string", ["foo"], "bar"); expect(result).toBe("string"); expect(spy).toHaveBeenCalled(); @@ -230,7 +230,7 @@ describe("updateValueAtPath", () => { }); test("returns original array when index is invalid", () => { - const spy = jest.spyOn(console, "error").mockImplementation(); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const arr = [1, 2, 3]; expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr); expect(spy).toHaveBeenCalled(); @@ -238,7 +238,7 @@ describe("updateValueAtPath", () => { }); test("returns original array when index is negative", () => { - const spy = jest.spyOn(console, "error").mockImplementation(); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const arr = [1, 2, 3]; expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr); expect(spy).toHaveBeenCalled(); diff --git a/client/src/utils/__tests__/oauthUtils.test.ts b/web/src/utils/__tests__/oauthUtils.test.ts similarity index 100% rename from client/src/utils/__tests__/oauthUtils.test.ts rename to web/src/utils/__tests__/oauthUtils.test.ts diff --git a/client/src/utils/__tests__/paramUtils.test.ts b/web/src/utils/__tests__/paramUtils.test.ts similarity index 100% rename from client/src/utils/__tests__/paramUtils.test.ts rename to web/src/utils/__tests__/paramUtils.test.ts diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/web/src/utils/__tests__/schemaUtils.test.ts similarity index 98% rename from client/src/utils/__tests__/schemaUtils.test.ts rename to web/src/utils/__tests__/schemaUtils.test.ts index 2d1b686f9..6f5a3bd94 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/web/src/utils/__tests__/schemaUtils.test.ts @@ -465,6 +465,8 @@ describe("Output Schema Validation", () => { }); test("handles invalid output schemas gracefully", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const toolsWithInvalidSchema: Tool[] = [ { name: "invalidSchemaTool", @@ -481,6 +483,15 @@ describe("Output Schema Validation", () => { expect(() => cacheToolOutputSchemas(toolsWithInvalidSchema), ).not.toThrow(); + + // Verify warning was logged + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to compile output schema for tool invalidSchemaTool:", + expect.any(Error), + ); + + consoleSpy.mockRestore(); + expect(hasOutputSchema("invalidSchemaTool")).toBe(false); }); }); diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/web/src/utils/__tests__/urlValidation.test.ts similarity index 100% rename from client/src/utils/__tests__/urlValidation.test.ts rename to web/src/utils/__tests__/urlValidation.test.ts diff --git a/client/src/utils/configUtils.ts b/web/src/utils/configUtils.ts similarity index 62% rename from client/src/utils/configUtils.ts rename to web/src/utils/configUtils.ts index bc081b8f8..66096a07b 100644 --- a/client/src/utils/configUtils.ts +++ b/web/src/utils/configUtils.ts @@ -1,8 +1,9 @@ import { InspectorConfig } from "@/lib/configurationTypes"; +import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants"; import { - DEFAULT_MCP_PROXY_LISTEN_PORT, - DEFAULT_INSPECTOR_CONFIG, -} from "@/lib/constants"; + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; const getSearchParam = (key: string): string | null => { try { @@ -13,20 +14,6 @@ const getSearchParam = (key: string): string | null => { } }; -export const getMCPProxyAddress = (config: InspectorConfig): string => { - let proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; - if (proxyFullAddress) { - proxyFullAddress = proxyFullAddress.replace(/\/+$/, ""); - return proxyFullAddress; - } - - // Check for proxy port from query params, fallback to default - const proxyPort = - getSearchParam("MCP_PROXY_PORT") || DEFAULT_MCP_PROXY_LISTEN_PORT; - - return `${window.location.protocol}//${window.location.hostname}:${proxyPort}`; -}; - export const getMCPServerRequestTimeout = (config: InspectorConfig): number => { return config.MCP_SERVER_REQUEST_TIMEOUT.value as number; }; @@ -43,17 +30,13 @@ export const getMCPServerRequestMaxTotalTimeout = ( return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number; }; -export const getMCPProxyAuthToken = ( +export const getInspectorApiToken = ( config: InspectorConfig, -): { - token: string; - header: string; -} => { - return { - token: config.MCP_PROXY_AUTH_TOKEN.value as string, - header: "X-MCP-Proxy-Auth", - }; +): string | undefined => { + const token = config.MCP_INSPECTOR_API_TOKEN.value as string; + return token || undefined; }; + export const getMCPTaskTtl = (config: InspectorConfig): number => { return config.MCP_TASK_TTL.value as number; }; @@ -118,6 +101,30 @@ export const getConfigOverridesFromQueryParams = ( return overrides; }; +/** + * Removes the Inspector API token query params from the current URL and + * replaces the history entry so the token is not visible in the address bar. + * Token is already in app state (config) by the time this is called. + */ +export const removeAuthTokenFromUrl = (): void => { + try { + const url = new URL(window.location.href); + const hasToken = + url.searchParams.has(API_SERVER_ENV_VARS.AUTH_TOKEN) || + url.searchParams.has(LEGACY_AUTH_TOKEN_ENV); + if (!hasToken) return; + + url.searchParams.delete(API_SERVER_ENV_VARS.AUTH_TOKEN); + url.searchParams.delete(LEGACY_AUTH_TOKEN_ENV); + const cleanSearch = url.searchParams.toString(); + const cleanUrl = + url.pathname + (cleanSearch ? `?${cleanSearch}` : "") + url.hash; + window.history.replaceState(undefined, "", cleanUrl); + } catch { + // Ignore URL/history errors (e.g. in tests or unsupported env) + } +}; + export const initializeInspectorConfig = ( localStorageKey: string, ): InspectorConfig => { @@ -131,19 +138,40 @@ export const initializeInspectorConfig = ( // Start with default config let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG }; - // Apply saved persistent config + // Helper function to filter config to only recognized keys + const filterRecognizedKeys = ( + parsedConfig: Partial, + ): Partial => { + const filtered: Partial = {}; + + for (const key in parsedConfig) { + if (key in DEFAULT_INSPECTOR_CONFIG) { + filtered[key as keyof InspectorConfig] = + parsedConfig[key as keyof InspectorConfig]; + } + } + + return filtered; + }; + + // Apply saved persistent config (filtered to recognized keys only) if (savedPersistentConfig) { const parsedPersistentConfig = JSON.parse(savedPersistentConfig); - baseConfig = { ...baseConfig, ...parsedPersistentConfig }; + const filteredPersistentConfig = filterRecognizedKeys( + parsedPersistentConfig, + ); + baseConfig = { ...baseConfig, ...filteredPersistentConfig }; } - // Apply saved ephemeral config + // Apply saved ephemeral config (filtered to recognized keys only) if (savedEphemeralConfig) { const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig); - baseConfig = { ...baseConfig, ...parsedEphemeralConfig }; + const filteredEphemeralConfig = filterRecognizedKeys(parsedEphemeralConfig); + baseConfig = { ...baseConfig, ...filteredEphemeralConfig }; } // Ensure all config items have the latest labels/descriptions from defaults + // (All keys at this point are guaranteed to exist in DEFAULT_INSPECTOR_CONFIG) for (const [key, value] of Object.entries(baseConfig)) { baseConfig[key as keyof InspectorConfig] = { ...value, @@ -155,9 +183,28 @@ export const initializeInspectorConfig = ( }; } - // Apply query param overrides + // Apply query param overrides (including API token from URL) const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG); - return { ...baseConfig, ...overrides }; + + // Check for API token in URL params (new name first, then legacy MCP_PROXY_AUTH_TOKEN) + const apiTokenFromUrl = + getSearchParam(API_SERVER_ENV_VARS.AUTH_TOKEN) ?? + getSearchParam("MCP_PROXY_AUTH_TOKEN"); + if (apiTokenFromUrl) { + overrides.MCP_INSPECTOR_API_TOKEN = { + ...DEFAULT_INSPECTOR_CONFIG.MCP_INSPECTOR_API_TOKEN, + value: apiTokenFromUrl, + }; + } + + const finalConfig = { ...baseConfig, ...overrides }; + + // Persist immediately when we got token from URL so new tabs (e.g. OAuth callback) have it + if (apiTokenFromUrl) { + saveInspectorConfig(localStorageKey, finalConfig); + } + + return finalConfig; }; export const saveInspectorConfig = ( diff --git a/client/src/utils/escapeUnicode.ts b/web/src/utils/escapeUnicode.ts similarity index 100% rename from client/src/utils/escapeUnicode.ts rename to web/src/utils/escapeUnicode.ts diff --git a/client/src/utils/jsonUtils.ts b/web/src/utils/jsonUtils.ts similarity index 100% rename from client/src/utils/jsonUtils.ts rename to web/src/utils/jsonUtils.ts diff --git a/client/src/utils/metaUtils.ts b/web/src/utils/metaUtils.ts similarity index 100% rename from client/src/utils/metaUtils.ts rename to web/src/utils/metaUtils.ts diff --git a/client/src/utils/oauthUtils.ts b/web/src/utils/oauthUtils.ts similarity index 100% rename from client/src/utils/oauthUtils.ts rename to web/src/utils/oauthUtils.ts diff --git a/client/src/utils/paramUtils.ts b/web/src/utils/paramUtils.ts similarity index 100% rename from client/src/utils/paramUtils.ts rename to web/src/utils/paramUtils.ts diff --git a/client/src/utils/schemaUtils.ts b/web/src/utils/schemaUtils.ts similarity index 84% rename from client/src/utils/schemaUtils.ts rename to web/src/utils/schemaUtils.ts index 692e05ba1..975f79b07 100644 --- a/client/src/utils/schemaUtils.ts +++ b/web/src/utils/schemaUtils.ts @@ -159,46 +159,20 @@ export function isPropertyRequired( * Resolves $ref references in JSON schema * @param schema The schema that may contain $ref * @param rootSchema The root schema to resolve references against - * @param visitedRefs Optional set of visited $ref paths to detect circular references * @returns The resolved schema without $ref */ export function resolveRef( schema: JsonSchemaType, rootSchema: JsonSchemaType, - visitedRefs: Set = new Set(), ): JsonSchemaType { - if (!schema) return schema; - if (!("$ref" in schema) || !schema.$ref) { - // Recursively resolve $ref in anyOf (and other nested structures) - if (schema.anyOf && Array.isArray(schema.anyOf)) { - const resolvedAnyOf = schema.anyOf.map((item) => { - if (typeof item === "object" && item !== null) { - return resolveRef(item, rootSchema, visitedRefs); - } - return item; - }); - return { - ...schema, - anyOf: resolvedAnyOf, - }; - } return schema; } const ref = schema.$ref; - // Handle all #/ formats (#/properties/, #/$defs/, etc.) + // Handle simple #/properties/name references if (ref.startsWith("#/")) { - // Check for circular reference - if (visitedRefs.has(ref)) { - console.warn(`Circular reference detected: ${ref}`); - return schema; - } - - // Add current ref to visited set - visitedRefs.add(ref); - const path = ref.substring(2).split("/"); let current: unknown = rootSchema; @@ -212,16 +186,12 @@ export function resolveRef( current = (current as Record)[segment]; } else { // If reference cannot be resolved, return the original schema - visitedRefs.delete(ref); // Clean up on failure console.warn(`Could not resolve $ref: ${ref}`); return schema; } } - const resolved = current as JsonSchemaType; - - // Recursively resolve nested structures (anyOf, oneOf, items, properties) - return resolveRef(resolved, rootSchema, visitedRefs); + return current as JsonSchemaType; } // For other types of references, return the original schema @@ -235,28 +205,54 @@ export function resolveRef( * @returns A normalized schema or the original schema */ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { - // Handle anyOf with exactly 2 items (type and null) - unified handling - // Preserves enum and other properties automatically + // Handle anyOf with exactly string and null (FastMCP pattern) if ( schema.anyOf && schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") ) { - const nonNullItem = schema.anyOf.find((t) => { - const item = t as JsonSchemaType; - return item?.type !== "null"; - }) as JsonSchemaType; - - // Only process if non-null item has type or enum - if (nonNullItem?.type || nonNullItem?.enum) { - return { - ...schema, - ...nonNullItem, - type: nonNullItem?.type || (nonNullItem?.enum ? "string" : undefined), - nullable: true, - anyOf: undefined, - }; - } + return { ...schema, type: "string", anyOf: undefined, nullable: true }; + } + + // Handle anyOf with exactly boolean and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "boolean", anyOf: undefined, nullable: true }; + } + + // Handle anyOf with exactly number and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "number", anyOf: undefined, nullable: true }; + } + + // Handle anyOf with exactly integer and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "integer", anyOf: undefined, nullable: true }; + } + + // Handle anyOf with exactly array and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "array", anyOf: undefined, nullable: true }; } // Handle array type with exactly string and null diff --git a/client/src/utils/urlValidation.ts b/web/src/utils/urlValidation.ts similarity index 100% rename from client/src/utils/urlValidation.ts rename to web/src/utils/urlValidation.ts diff --git a/client/src/vite-env.d.ts b/web/src/vite-env.d.ts similarity index 100% rename from client/src/vite-env.d.ts rename to web/src/vite-env.d.ts diff --git a/server/static/sandbox_proxy.html b/web/static/sandbox_proxy.html similarity index 100% rename from server/static/sandbox_proxy.html rename to web/static/sandbox_proxy.html diff --git a/client/tailwind.config.js b/web/tailwind.config.js similarity index 87% rename from client/tailwind.config.js rename to web/tailwind.config.js index c7a45811c..aeeda19db 100644 --- a/client/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,8 +1,16 @@ /** @type {import('tailwindcss').Config} */ +import path from "path"; +import { fileURLToPath } from "url"; import animate from "tailwindcss-animate"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + export default { darkMode: ["class"], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: [ + path.join(__dirname, "index.html"), + path.join(__dirname, "src/**/*.{js,ts,jsx,tsx}"), + ], theme: { extend: { borderRadius: { diff --git a/client/tsconfig.app.json b/web/tsconfig.app.json similarity index 79% rename from client/tsconfig.app.json rename to web/tsconfig.app.json index 6667055c8..8af24a7a6 100644 --- a/client/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -25,8 +25,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "resolveJsonModule": true, - "types": ["jest", "@testing-library/jest-dom", "node"] + "types": ["vitest/globals", "@testing-library/jest-dom", "node"] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**"] + "include": ["src"] } diff --git a/client/tsconfig.json b/web/tsconfig.json similarity index 100% rename from client/tsconfig.json rename to web/tsconfig.json diff --git a/client/tsconfig.node.json b/web/tsconfig.node.json similarity index 100% rename from client/tsconfig.node.json rename to web/tsconfig.node.json diff --git a/web/tsconfig.server.json b/web/tsconfig.server.json new file mode 100644 index 000000000..251247812 --- /dev/null +++ b/web/tsconfig.server.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": false + }, + "include": ["src/server.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..200d89f88 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,260 @@ +import react from "@vitejs/plugin-react"; +import path from "path"; +import { readFileSync } from "node:fs"; +import { defineConfig, type Plugin } from "vite"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import pino from "pino"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; +import { + createSandboxController, + resolveSandboxPort, +} from "./src/sandbox-controller.js"; + +/** + * Vite plugin that adds Hono middleware to handle /api/* routes + * and starts the MCP Apps sandbox server (same process; port from MCP_SANDBOX_PORT / SERVER_PORT / dynamic). + */ +function honoMiddlewarePlugin(options: { + authToken?: string; + dangerouslyOmitAuth?: boolean; +}): Plugin { + return { + name: "hono-api-middleware", + async configureServer(server) { + const sandboxHtmlPath = path.join( + __dirname, + "static", + "sandbox_proxy.html", + ); + let sandboxHtml: string; + try { + sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); + } catch { + sandboxHtml = + "Sandbox not loaded"; + } + + const sandboxController = createSandboxController({ + port: resolveSandboxPort(), + sandboxHtml, + host: "localhost", + }); + await sandboxController.start(); + + server.httpServer?.on("close", () => { + sandboxController.close().catch((err) => { + console.error("Sandbox close error:", err); + }); + }); + + // When Vitest (or anything) calls server.close(), close the sandbox first so the process can exit + const originalClose = server.close.bind(server); + server.close = async () => { + await sandboxController.close(); + return originalClose(); + }; + + const { app: honoApp, authToken: resolvedToken } = createRemoteApp({ + authToken: options.dangerouslyOmitAuth ? undefined : options.authToken, + dangerouslyOmitAuth: options.dangerouslyOmitAuth, + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: [ + `http://localhost:${process.env.CLIENT_PORT || "6274"}`, + `http://127.0.0.1:${process.env.CLIENT_PORT || "6274"}`, + ], + sandboxUrl: sandboxController.getUrl() ?? undefined, + logger: process.env.MCP_LOG_FILE + ? pino( + { level: "info" }, + pino.destination({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + }), + ) + : undefined, + }); + + // When no token was provided via env (e.g. `npm run dev` from web), log the generated token so the user can add it to the URL or paste in the token modal + if (!options.dangerouslyOmitAuth && !options.authToken && resolvedToken) { + const port = process.env.CLIENT_PORT || "6274"; + const host = process.env.HOST || "localhost"; + console.log( + `\nšŸ”‘ Inspector API token (add to URL or paste in token modal):\n ${resolvedToken}\n Or open: http://${host}:${port}/?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${resolvedToken}\n`, + ); + } + const sandboxUrl = sandboxController.getUrl(); + if (sandboxUrl) { + if (server.httpServer) { + server.httpServer.once("listening", () => { + setImmediate(() => { + console.log(` āžœ Sandbox (MCP Apps): ${sandboxUrl}`); + }); + }); + } else { + console.log(` āžœ Sandbox (MCP Apps): ${sandboxUrl}`); + } + } + + // Convert Connect middleware to handle Hono app + const honoMiddleware = async ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) => { + try { + // Only handle /api/* routes, let others pass through to Vite + const path = req.url || ""; + if (!path.startsWith("/api")) { + return next(); + } + + const url = `http://${req.headers.host}${path}`; + + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) { + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + }); + + const init: RequestInit = { + method: req.method, + headers, + }; + + // Handle body for non-GET requests + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => { + req.on("end", () => resolve()); + }); + if (chunks.length > 0) { + init.body = Buffer.concat(chunks); + } + } + + const request = new Request(url, init); + const response = await honoApp.fetch(request); + + // Convert Web Standard Response back to Node res + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + // For SSE streams, we need to stream data immediately without buffering + const isSSE = response.headers + .get("content-type") + ?.includes("text/event-stream"); + if (isSSE) { + // Disable buffering for SSE + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + } + + if (response.body) { + // Flush headers immediately so the client gets 200 before any body chunks. + // Otherwise for SSE (no data until first event) reader.read() blocks and + // Node never sends headers, so the client's fetch() hangs. + res.flushHeaders?.(); + const reader = response.body.getReader(); + const pump = async () => { + try { + const { done, value } = await reader.read(); + if (done) { + res.end(); + } else { + // Write immediately without buffering + res.write(Buffer.from(value), (err) => { + if (err) { + console.error("[Hono Middleware] Write error:", err); + reader.cancel().catch(() => {}); + res.end(); + } + }); + // Continue pumping (don't await, but handle errors) + pump().catch((err) => { + console.error("[Hono Middleware] Pump error:", err); + reader.cancel().catch(() => {}); + res.end(); + }); + } + } catch (err) { + console.error("[Hono Middleware] Read error:", err); + reader.cancel().catch(() => {}); + res.end(); + } + }; + // Start pumping (don't await - let it run in background for SSE) + pump(); + } else { + res.end(); + } + } catch (error) { + next(error); + } + }; + + // Mount at root - check path ourselves to avoid Connect prefix stripping + // Only handle /api/* routes, let others pass through to Vite + server.middlewares.use(honoMiddleware); + }, + }; +} + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + // Inspector API auth token and DANGEROUSLY_OMIT_AUTH are passed via env (set by start script or user). + // When unset (e.g. running `npm run dev` from web), createRemoteApp generates one; plugin logs it below. + honoMiddlewarePlugin({ + authToken: + process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || + process.env[LEGACY_AUTH_TOKEN_ENV] || + undefined, + dangerouslyOmitAuth: !!process.env.DANGEROUSLY_OMIT_AUTH, + }), + ], + server: { + host: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + // Prevent bundling Node.js-only modules + conditions: ["browser", "module", "import"], + }, + build: { + minify: false, + rollupOptions: { + output: { + manualChunks: undefined, + }, + external: [ + // Prevent bundling Node.js-only stdio transport code + "@modelcontextprotocol/sdk/client/stdio.js", + "cross-spawn", + "which", + ], + }, + }, + optimizeDeps: { + exclude: [ + // Exclude Node.js-only modules from pre-bundling + "@modelcontextprotocol/sdk/client/stdio.js", + "@modelcontextprotocol/inspector-core/mcp/node", + "@modelcontextprotocol/inspector-core/mcp/remote/node", + "cross-spawn", + "which", + ], + }, +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 000000000..f407232ea --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,39 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +// Extend Vite config for Vitest (shared resolve, plugins) +export default defineConfig({ + ...viteConfig, + plugins: viteConfig.plugins || [], + resolve: { + ...viteConfig.resolve, + alias: { + ...viteConfig.resolve?.alias, + "\\.css$": path.resolve(__dirname, "src/__mocks__/styleMock.js"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test-setup.ts"], + include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"], + exclude: [ + "node_modules", + "dist", + "bin", + "e2e", + "**/*.config.{js,ts,cjs,mjs}", + ], + coverage: { + exclude: [ + "node_modules", + "dist", + "bin", + "e2e", + "**/*.config.{js,ts,cjs,mjs}", + "**/__mocks__/**", + ], + }, + }, +});