diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a3e960e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Version control +.git +.gitignore + +# Node.js +node_modules +npm-debug.log + +# Build artifacts +client/dist +client/build +server/dist +server/build + +# Environment variables +.env +.env.local +.env.development +.env.test +.env.production + +# Editor files +.vscode +.idea + +# Logs +logs +*.log + +# Testing +coverage + +# Docker +Dockerfile +.dockerignore \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml new file mode 100644 index 00000000..2630a495 --- /dev/null +++ b/.github/workflows/e2e_tests.yml @@ -0,0 +1,76 @@ +name: Playwright Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + # Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load. + timeout-minutes: 15 + runs-on: ubuntu-latest + + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwoff1 + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + # Cache Playwright browsers + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright # The default Playwright cache path + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install dependencies + run: npm ci + + - name: Install Playwright dependencies + run: npx playwright install-deps + + - name: Install Playwright and browsers unless cached + run: npx playwright install --with-deps + if: steps.cache-playwright.outputs.cache-hit != 'true' + + - name: Run Playwright tests + id: playwright-tests + run: npm run test:e2e + + - name: Upload Playwright Report and Screenshots + uses: actions/upload-artifact@v4 + if: steps.playwright-tests.conclusion != 'skipped' + with: + name: playwright-report + path: | + client/playwright-report/ + client/test-results/ + client/results.json + retention-days: 2 + + - name: Publish Playwright Test Summary + uses: daun/playwright-report-summary@v3 + if: steps.playwright-tests.conclusion != 'skipped' + with: + create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + report-file: client/results.json + comment-title: "šŸŽ­ Playwright E2E Test Results" + job-summary: true + icon-style: "emojis" + custom-info: | + **Test Environment:** Ubuntu Latest, Node.js 18 + **Browsers:** Chromium, Firefox + + šŸ“Š [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) + test-command: "npm run test:e2e" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 54addc87..199e33a4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,6 +26,9 @@ jobs: # - run: npm ci - run: npm install --no-package-lock + - name: Check version consistency + run: npm run check-version + - name: Check linting working-directory: ./client run: npm run lint @@ -62,3 +65,52 @@ jobs: - run: npm run publish-all env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-github-container-registry: + runs-on: ubuntu-latest + if: github.event_name == 'release' + environment: release + needs: build + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.gitignore b/.gitignore index 60faf653..8e184de8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo cli/build test-output +client/playwright-report/ +client/results.json +client/test-results/ + diff --git a/CLAUDE.md b/CLAUDE.md index e62fd188..ec826b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,4 +30,4 @@ The project is organized as a monorepo with workspaces: - `client/`: React frontend with Vite, TypeScript and Tailwind - `server/`: Express backend with TypeScript -- `bin/`: CLI scripts +- `cli/`: Command-line interface for testing and invoking MCP server methods directly diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72502f96..0129a9e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,13 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Fork the repository and clone it locally 2. Install dependencies with `npm install` 3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://127.0.0.1:6274 to interact with the inspector +4. Use the web UI at http://localhost:6274 to interact with the inspector ## Development Process & Pull Requests 1. Create a new branch for your changes 2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. -3. Test changes locally by running `npm test` +3. Test changes locally by running `npm test` and `npm run test:e2e` 4. Update documentation as needed 5. Use clear commit messages explaining your changes 6. Verify all changes work as expected diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f36fb8bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Build stage +FROM node:24-slim AS builder + +# Set working directory +WORKDIR /app + +# Copy package files for installation +COPY package*.json ./ +COPY .npmrc ./ +COPY client/package*.json ./client/ +COPY server/package*.json ./server/ +COPY cli/package*.json ./cli/ + +# Install dependencies +RUN npm ci --ignore-scripts + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:24-slim + +WORKDIR /app + +# Copy package files for production +COPY package*.json ./ +COPY .npmrc ./ +COPY client/package*.json ./client/ +COPY server/package*.json ./server/ +COPY cli/package*.json ./cli/ + +# 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/cli/build ./cli/build + +# Set default port values as environment variables +ENV CLIENT_PORT=6274 +ENV SERVER_PORT=6277 + +# Document which ports the application uses internally +EXPOSE ${CLIENT_PORT} ${SERVER_PORT} + +# Use ENTRYPOINT with CMD for arguments +ENTRYPOINT ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md index d7c34b97..6368ed04 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +## Architecture Overview + +The MCP Inspector consists of two main components that work together: + +- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers +- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http) + +Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols. + ## Running the Inspector ### Requirements - Node.js: ^22.7.5 +### Quick Start (UI mode) + +To get up and running right away with the UI, just execute the following: + +```bash +npx @modelcontextprotocol/inspector +``` + +The server will start up and the UI will be accessible at `http://localhost:6274`. + ### From an MCP server repository To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: @@ -118,17 +137,64 @@ The inspector supports bearer token authentication for SSE connections. Enter yo The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server. +#### Authentication + +The MCP Inspector proxy server requires authentication by default. When starting the server, a random session token is generated and printed to the console: + +``` +šŸ”‘ Session token: 3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 + +šŸ”— Open inspector with token pre-filled: + http://localhost:6274/?MCP_PROXY_AUTH_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. + +**Automatic browser opening** - The inspector now automatically opens your browser with the token pre-filled in the URL when authentication is enabled. + +**Alternative: Manual configuration** - If you already have the inspector open: + +1. Click the "Configuration" button in the sidebar +2. Find "Proxy Session Token" and enter the token displayed in the proxy console +3. Click "Save" to apply the configuration + +The token will be saved in your browser's local storage for future use. + +If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGEROUSLY_OMIT_AUTH` environment variable: + +```bash +DANGEROUSLY_OMIT_AUTH=true npm start +``` + +#### Local-only Binding + +By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: + +```bash +HOST=0.0.0.0 npm start +``` + +**Warning:** Only bind to all interfaces in trusted network environments, as this exposes the proxy server's ability to execute local processes and both services to network access. + +#### DNS Rebinding Protection + +To prevent DNS rebinding attacks, the MCP Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed (respects `CLIENT_PORT` if set, defaulting to port 6274). You can configure additional allowed origins by setting the `ALLOWED_ORIGINS` environment variable (comma-separated list): + +```bash +ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start +``` + ### Configuration The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: -| Setting | Description | Default | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- | -| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | -| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | -| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | -| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | -| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true | +| Setting | Description | Default | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | +| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | +| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true | These settings can be adjusted in real-time through the UI and will persist across sessions. @@ -233,9 +299,12 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method resources # List available prompts npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list -# Connect to a remote MCP server +# Connect to a remote MCP server (default is SSE transport) npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com +# Connect to a remote MCP server (with Streamable HTTP transport) +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http + # Call a tool on a remote server npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value diff --git a/cli/package.json b/cli/package.json index 55f12e15..bfb173a8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.13.0", + "version": "0.15.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 6e6b56f9..51bda553 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -44,7 +44,14 @@ console.log(`${colors.BLUE}- Resource-related options (--uri)${colors.NC}`); console.log( `${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`, ); -console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`); +console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}`); +console.log( + `${colors.BLUE}- Transport types (--transport http/sse/stdio)${colors.NC}`, +); +console.log( + `${colors.BLUE}- Transport inference from URL suffixes (/mcp, /sse)${colors.NC}`, +); +console.log(`\n`); // Get directory paths const SCRIPTS_DIR = __dirname; @@ -62,9 +69,11 @@ if (!fs.existsSync(OUTPUT_DIR)) { } // Create a temporary directory for test files -const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), { - recursive: true, -}); +const TEMP_DIR = path.join(os.tmpdir(), "mcp-inspector-tests"); +fs.mkdirSync(TEMP_DIR, { recursive: true }); + +// Track servers for cleanup +let runningServers = []; process.on("exit", () => { try { @@ -74,6 +83,21 @@ process.on("exit", () => { `${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`, ); } + + runningServers.forEach((server) => { + try { + process.kill(-server.pid); + } catch (e) {} + }); +}); + +process.on("SIGINT", () => { + runningServers.forEach((server) => { + try { + process.kill(-server.pid); + } catch (e) {} + }); + process.exit(1); }); // Use the existing sample config file @@ -121,6 +145,11 @@ async function runBasicTest(testName, ...args) { stdio: ["ignore", "pipe", "pipe"], }); + const timeout = setTimeout(() => { + console.log(`${colors.YELLOW}Test timed out: ${testName}${colors.NC}`); + child.kill(); + }, 10000); + // Pipe stdout and stderr to the output file child.stdout.pipe(outputStream); child.stderr.pipe(outputStream); @@ -135,6 +164,7 @@ async function runBasicTest(testName, ...args) { }); child.on("close", (code) => { + clearTimeout(timeout); outputStream.end(); if (code === 0) { @@ -201,6 +231,13 @@ async function runErrorTest(testName, ...args) { stdio: ["ignore", "pipe", "pipe"], }); + const timeout = setTimeout(() => { + console.log( + `${colors.YELLOW}Error test timed out: ${testName}${colors.NC}`, + ); + child.kill(); + }, 10000); + // Pipe stdout and stderr to the output file child.stdout.pipe(outputStream); child.stderr.pipe(outputStream); @@ -215,6 +252,7 @@ async function runErrorTest(testName, ...args) { }); child.on("close", (code) => { + clearTimeout(timeout); outputStream.end(); // For error tests, we expect a non-zero exit code @@ -611,6 +649,79 @@ async function runTests() { "debug", ); + console.log( + `\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`, + ); + + console.log( + `${colors.BLUE}Starting server-everything in streamableHttp mode.${colors.NC}`, + ); + const httpServer = spawn( + "npx", + ["@modelcontextprotocol/server-everything", "streamableHttp"], + { + detached: true, + stdio: "ignore", + }, + ); + runningServers.push(httpServer); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Test 25: HTTP transport inferred from URL ending with /mcp + await runBasicTest( + "http_transport_inferred", + "http://127.0.0.1:3001/mcp", + "--cli", + "--method", + "tools/list", + ); + + // Test 26: HTTP transport with explicit --transport http flag + await runBasicTest( + "http_transport_with_explicit_flag", + "http://127.0.0.1:3001", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 27: HTTP transport with suffix and --transport http flag + await runBasicTest( + "http_transport_with_explicit_flag_and_suffix", + "http://127.0.0.1:3001/mcp", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 28: SSE transport given to HTTP server (should fail) + await runErrorTest( + "sse_transport_given_to_http_server", + "http://127.0.0.1:3001", + "--transport", + "sse", + "--cli", + "--method", + "tools/list", + ); + + // Kill HTTP server + try { + process.kill(-httpServer.pid); + console.log( + `${colors.BLUE}HTTP server killed, waiting for port to be released...${colors.NC}`, + ); + } catch (e) { + console.log( + `${colors.RED}Error killing HTTP server: ${e.message}${colors.NC}`, + ); + } + // Print test summary console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`); console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`); diff --git a/cli/src/index.ts b/cli/src/index.ts index bda1e8f7..5d5dcf8b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -29,9 +29,13 @@ type Args = { logLevel?: LogLevel; toolName?: string; toolArg?: Record; + transport?: "sse" | "stdio" | "http"; }; -function createTransportOptions(target: string[]): TransportOptions { +function createTransportOptions( + target: string[], + transport?: "sse" | "stdio" | "http", +): TransportOptions { if (target.length === 0) { throw new Error( "Target is required. Specify a URL or a command to execute.", @@ -50,8 +54,30 @@ function createTransportOptions(target: string[]): TransportOptions { throw new Error("Arguments cannot be passed to a URL-based MCP server."); } + let transportType: "sse" | "stdio" | "http"; + if (transport) { + if (!isUrl && transport !== "stdio") { + throw new Error("Only stdio transport can be used with local commands."); + } + if (isUrl && 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"; + } else { + transportType = "sse"; + } + } else { + transportType = "stdio"; + } + return { - transportType: isUrl ? "sse" : "stdio", + transportType, command: isUrl ? undefined : command, args: isUrl ? undefined : commandArgs, url: isUrl ? command : undefined, @@ -59,7 +85,7 @@ function createTransportOptions(target: string[]): TransportOptions { } async function callMethod(args: Args): Promise { - const transportOptions = createTransportOptions(args.target); + const transportOptions = createTransportOptions(args.target, args.transport); const transport = createTransport(transportOptions); const client = new Client({ name: "inspector-cli", @@ -214,6 +240,22 @@ function parseArgs(): Args { return value as LogLevel; }, + ) + // + // Transport options + // + .option( + "--transport ", + "Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio", + (value: string) => { + const validTransports = ["sse", "http", "stdio"]; + if (!validTransports.includes(value)) { + throw new Error( + `Invalid transport type: ${value}. Valid types are: ${validTransports.join(", ")}`, + ); + } + return value as "sse" | "http" | "stdio"; + }, ); // Parse only the arguments before -- diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e693f246..e0d67b4e 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -3,11 +3,12 @@ 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"; + transportType: "sse" | "stdio" | "http"; command?: string; args?: string[]; url?: string; @@ -15,11 +16,22 @@ export type TransportOptions = { function createSSETransport(options: TransportOptions): Transport { const baseUrl = new URL(options.url ?? ""); - const sseUrl = new URL("/sse", baseUrl); + const sseUrl = baseUrl.pathname.endsWith("/sse") + ? baseUrl + : new URL("/sse", baseUrl); return new SSEClientTransport(sseUrl); } +function createHTTPTransport(options: TransportOptions): Transport { + const baseUrl = new URL(options.url ?? ""); + const mcpUrl = baseUrl.pathname.endsWith("/mcp") + ? baseUrl + : new URL("/mcp", baseUrl); + + return new StreamableHTTPClientTransport(mcpUrl); +} + function createStdioTransport(options: TransportOptions): Transport { let args: string[] = []; @@ -67,6 +79,10 @@ export function createTransport(options: TransportOptions): Transport { return createSSETransport(options); } + if (transportType === "http") { + return createHTTPTransport(options); + } + throw new Error(`Unsupported transport type: ${transportType}`); } catch (error) { throw new Error( diff --git a/client/bin/client.js b/client/bin/client.js index 7179e19e..2a7419e6 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +import open from "open"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import handler from "serve-handler"; @@ -39,19 +40,23 @@ const server = http.createServer((request, response) => { return handler(request, response, handlerOptions); }); -const port = process.env.PORT || 6274; +const port = parseInt(process.env.CLIENT_PORT || "6274", 10); +const host = process.env.HOST || "localhost"; server.on("listening", () => { - console.log( - `šŸ” MCP Inspector is up and running at http://127.0.0.1:${port} šŸš€`, - ); + 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://127.0.0.1:${port} āŒ `, + `āŒ MCP Inspector PORT IS IN USE at http://${host}:${port} āŒ `, ); } else { throw err; } }); -server.listen(port); +server.listen(port, host); diff --git a/client/bin/start.js b/client/bin/start.js index 8e383b38..70ca046e 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -2,15 +2,202 @@ import open from "open"; import { resolve, dirname } from "path"; -import { spawnPromise } from "spawn-rx"; +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 } = + 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_TOKEN: sessionToken, + MCP_ENV_VARS: JSON.stringify(envVars), + }, + signal: abort.signal, + echoOutput: true, + }; + + // For Windows, we need to use stdin: 'ignore' to simulate < NUL + if (isWindows) { + spawnOptions.stdin = "ignore"; + } + + 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, + } = serverOptions; + const inspectorServerPath = resolve( + __dirname, + "../..", + "server", + "build", + "index.js", + ); + + const server = spawnPromise( + "node", + [ + inspectorServerPath, + ...(command ? [`--env`, command] : []), + ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ], + { + env: { + ...process.env, + SERVER_PORT, + CLIENT_PORT, + MCP_PROXY_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 client = spawn(clientCommand, clientArgs, { + cwd: resolve(__dirname, ".."), + env: { ...process.env, CLIENT_PORT }, + signal: abort.signal, + echoOutput: true, + }); + + 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); @@ -18,6 +205,7 @@ async function main() { const mcpServerArgs = []; let command = null; let parsingFlags = true; + let isDev = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -27,6 +215,11 @@ async function main() { continue; } + if (parsingFlags && arg === "--dev") { + isDev = true; + continue; + } + if (parsingFlags && arg === "-e" && i + 1 < args.length) { const envVar = args[++i]; const equalsIndex = envVar.indexOf("="); @@ -38,34 +231,25 @@ async function main() { } else { envVars[envVar] = ""; } - } else if (!command) { + } else if (!command && !isDev) { command = arg; - } else { + } else if (!isDev) { mcpServerArgs.push(arg); } } - const inspectorServerPath = resolve( - __dirname, - "../..", - "server", - "build", - "index.js", - ); - - // Path to the client entry point - const inspectorClientPath = resolve( - __dirname, - "../..", - "client", - "bin", - "client.js", - ); - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; + const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; - console.log("Starting MCP inspector..."); + console.log( + isDev + ? "Starting MCP inspector in development mode..." + : "Starting MCP inspector...", + ); + + // Generate session token for authentication + const sessionToken = randomBytes(32).toString("hex"); + const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; const abort = new AbortController(); @@ -74,40 +258,42 @@ async function main() { cancelled = true; abort.abort(); }); - let server, serverOk; - try { - server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), - ], - { - env: { - ...process.env, - PORT: SERVER_PORT, - MCP_ENV_VARS: JSON.stringify(envVars), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - // Make sure server started before starting client - serverOk = await Promise.race([server, delay(2 * 1000)]); + let server, serverOk; + + try { + const serverOptions = { + SERVER_PORT, + CLIENT_PORT, + sessionToken, + envVars, + abort, + command, + mcpServerArgs, + }; + + const result = isDev + ? await startDevServer(serverOptions) + : await startProdServer(serverOptions); + + server = result.server; + serverOk = result.serverOk; } catch (error) {} if (serverOk) { try { - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - open(`http://127.0.0.1:${CLIENT_PORT}`); - } - await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }); + 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; } diff --git a/client/e2e/global-teardown.js b/client/e2e/global-teardown.js new file mode 100644 index 00000000..9308d940 --- /dev/null +++ b/client/e2e/global-teardown.js @@ -0,0 +1,18 @@ +import { rimraf } from "rimraf"; + +async function globalTeardown() { + if (!process.env.CI) { + console.log("Cleaning up test-results directory..."); + // Add a small delay to ensure all Playwright files are written + await new Promise((resolve) => setTimeout(resolve, 100)); + await rimraf("./e2e/test-results"); + console.log("Test-results directory cleaned up."); + } +} + +export default globalTeardown; + +// Call the function when this script is run directly +if (import.meta.url === `file://${process.argv[1]}`) { + globalTeardown().catch(console.error); +} diff --git a/client/e2e/transport-type-dropdown.spec.ts b/client/e2e/transport-type-dropdown.spec.ts new file mode 100644 index 00000000..e4ac8fbb --- /dev/null +++ b/client/e2e/transport-type-dropdown.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; + +// Adjust the URL if your dev server runs on a different port +const APP_URL = "http://localhost:6274/"; + +test.describe("Transport Type Dropdown", () => { + test("should have options for STDIO, SSE, and Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown + await selectTrigger.click(); + + // Check for the three options + await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible(); + await expect(page.getByRole("option", { name: "SSE" })).toBeVisible(); + await expect( + page.getByRole("option", { name: "Streamable HTTP" }), + ).toBeVisible(); + }); + + test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select STDIO + await selectTrigger.click(); + await page.getByRole("option", { name: "STDIO" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that Command and Arguments fields are visible + await expect(page.locator("#command-input")).toBeVisible(); + await expect(page.locator("#arguments-input")).toBeVisible(); + + // Check that URL field is not visible + await expect(page.locator("#sse-url-input")).not.toBeVisible(); + + // Also verify the labels are present + await expect(page.getByText("Command")).toBeVisible(); + await expect(page.getByText("Arguments")).toBeVisible(); + await expect(page.getByText("URL")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select SSE + await selectTrigger.click(); + await page.getByRole("option", { name: "SSE" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select Streamable HTTP + await selectTrigger.click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); +}); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index b4d81cfc..8652eda5 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -21,6 +21,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], // Exclude the same patterns from coverage reports @@ -28,6 +29,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], }; diff --git a/client/package.json b/client/package.json index ee54df3d..59bf2288 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.13.0", + "version": "0.15.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -20,10 +20,12 @@ "lint": "eslint .", "preview": "vite preview --port 6274", "test": "jest --config jest.config.cjs", - "test:watch": "jest --config jest.config.cjs --watch" + "test:watch": "jest --config jest.config.cjs --watch", + "test:e2e": "playwright test e2e && npm run cleanup:e2e", + "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "@radix-ui/react-checkbox": "^1.1.4", "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", @@ -38,7 +40,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", - "lucide-react": "^0.447.0", + "lucide-react": "^0.523.0", "pkce-challenge": "^4.1.0", "prismjs": "^1.30.0", "react": "^18.3.1", diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..570dd054 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + /* Run your local dev server before starting the tests */ + webServer: { + cwd: "..", + command: "npm run dev", + url: "http://localhost:6274", + reuseExistingServer: !process.env.CI, + }, + + testDir: "./e2e", + outputDir: "./e2e/test-results", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report" }], + ["json", { outputFile: "results.json" }], + ["line"], + ] + : [["line"]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:6274", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Take screenshots on failure */ + screenshot: "only-on-failure", + + /* Record video on failure */ + video: "retain-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + // Skip WebKit on macOS due to compatibility issues + ...(process.platform !== "darwin" + ? [ + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ] + : []), + ], +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index 396f5051..26eb44c5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; -import { AuthDebuggerState } from "./lib/auth-types"; +import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; +import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import React, { Suspense, @@ -29,7 +30,10 @@ import React, { useState, } from "react"; import { useConnection } from "./lib/hooks/useConnection"; -import { useDraggablePane } from "./lib/hooks/useDraggablePane"; +import { + useDraggablePane, + useDraggableSidebar, +} from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -59,11 +63,13 @@ import ToolsTab from "./components/ToolsTab"; import { InspectorConfig } from "./lib/configurationTypes"; import { getMCPProxyAddress, + getMCPProxyAuthToken, getInitialSseUrl, getInitialTransportType, getInitialCommand, getInitialArgs, initializeInspectorConfig, + saveInspectorConfig, } from "./utils/configUtils"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -121,19 +127,8 @@ const App = () => { const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state - const [authState, setAuthState] = useState({ - isInitiatingAuth: false, - oauthTokens: null, - loading: true, - oauthStep: "metadata_discovery", - oauthMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }); + const [authState, setAuthState] = + useState(EMPTY_DEBUGGER_STATE); // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { @@ -164,6 +159,11 @@ const App = () => { const progressTokenRef = useRef(0); const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const { + width: sidebarWidth, + isDragging: isSidebarDragging, + handleDragStart: handleSidebarDragStart, + } = useDraggableSidebar(320); const { connectionStatus, @@ -228,7 +228,7 @@ const App = () => { }, [headerName]); useEffect(() => { - localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); // Auto-connect to previously saved serverURL after OAuth callback @@ -243,27 +243,81 @@ const App = () => { // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( - ({ + async ({ authorizationCode, errorMsg, + restoredState, }: { authorizationCode?: string; errorMsg?: string; + restoredState?: AuthDebuggerState; }) => { setIsAuthDebuggerVisible(true); - if (authorizationCode) { + + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + return; + } + + if (restoredState && authorizationCode) { + // Restore the previous auth state and continue the OAuth flow + let currentState: AuthDebuggerState = { + ...restoredState, + authorizationCode, + oauthStep: "token_request", + isInitiatingAuth: true, + statusMessage: null, + latestError: null, + }; + + try { + // Create a new state machine instance to continue the flow + const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { + currentState = { ...currentState, ...updates }; + }); + + // Continue stepping through the OAuth flow from where we left off + while ( + currentState.oauthStep !== "complete" && + currentState.oauthStep !== "authorization_code" + ) { + await stateMachine.executeStep(currentState); + } + + if (currentState.oauthStep === "complete") { + // After the flow completes or reaches a user-input step, update the app state + 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) { + // Fallback to the original behavior if no state was restored updateAuthState({ authorizationCode, oauthStep: "token_request", }); } - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), - }); - } }, - [], + [sseUrl], ); // Load OAuth tokens when sseUrl changes @@ -285,8 +339,6 @@ const App = () => { } } catch (error) { console.error("Error loading OAuth tokens:", error); - } finally { - updateAuthState({ loading: false }); } }; @@ -294,7 +346,14 @@ const App = () => { }, [sseUrl]); useEffect(() => { - fetch(`${getMCPProxyAddress(config)}/config`) + 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); @@ -308,8 +367,7 @@ const App = () => { .catch((error) => console.error("Error fetching default environment:", error), ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [config]); useEffect(() => { rootsRef.current = roots; @@ -565,32 +623,58 @@ const App = () => { return (
- +
+ + {/* Drag handle for resizing sidebar */} +
+
{mcpClient ? ( diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx new file mode 100644 index 00000000..b2e03d36 --- /dev/null +++ b/client/src/__tests__/App.config.test.tsx @@ -0,0 +1,240 @@ +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: [], + 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/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index d0b0432d..ec09963e 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,10 +1,11 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; import { AlertCircle } from "lucide-react"; -import { AuthDebuggerState } from "../lib/auth-types"; +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"; export interface AuthDebuggerProps { serverUrl: string; @@ -59,6 +60,27 @@ const AuthDebugger = ({ 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({ @@ -141,6 +163,11 @@ const AuthDebugger = ({ currentState.oauthStep === "authorization_code" && currentState.authorizationUrl ) { + // 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; break; @@ -178,13 +205,7 @@ const AuthDebugger = ({ ); serverAuthProvider.clear(); updateAuthState({ - oauthTokens: null, - oauthStep: "metadata_discovery", - latestError: null, - oauthClientInfo: null, - authorizationCode: "", - validationError: null, - oauthMetadata: null, + ...EMPTY_DEBUGGER_STATE, statusMessage: { type: "success", message: "OAuth tokens cleared successfully", @@ -224,52 +245,48 @@ const AuthDebugger = ({ )} - {authState.loading ? ( -

Loading authentication status...

- ) : ( -
- {authState.oauthTokens && ( -
-

Access Token:

-
- {authState.oauthTokens.access_token.substring(0, 25)}... -
+
+ {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. -

+
+ + + + +
- )} + +

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

+
{ const supportedTypes = ["string", "number", "integer", "boolean", "null"]; if (supportedTypes.includes(schema.type)) return true; - if (schema.type !== "object") return false; - return Object.values(schema.properties ?? {}).every((prop) => - supportedTypes.includes(prop.type), - ); + if (schema.type === "object") { + return Object.values(schema.properties ?? {}).every((prop) => + supportedTypes.includes(prop.type), + ); + } + if (schema.type === "array") { + return !!schema.items && isSimpleObject(schema.items); + } + return false; +}; + +const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { + if ("default" in schema && schema.default !== undefined) { + return schema.default; + } + + switch (schema.type) { + case "string": + return ""; + case "number": + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + case "object": + return {}; + case "null": + return null; + default: + return null; + } }; const DynamicJsonForm = ({ @@ -113,6 +142,8 @@ const DynamicJsonForm = ({ currentValue: JsonValue, path: string[] = [], depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, ) => { if ( depth >= maxDepth && @@ -122,7 +153,8 @@ const DynamicJsonForm = ({ return ( { const val = e.target.value; - // Allow clearing non-required fields by setting undefined - // This preserves the distinction between empty string and unset - if (!val && !propSchema.required) { - handleFieldChange(path, undefined); - } else { - handleFieldChange(path, val); - } + // Always allow setting string values, including empty strings + handleFieldChange(path, val); }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "number": @@ -167,9 +198,7 @@ const DynamicJsonForm = ({ value={(currentValue as number)?.toString() ?? ""} onChange={(e) => { const val = e.target.value; - // Allow clearing non-required number fields - // This preserves the distinction between 0 and unset - if (!val && !propSchema.required) { + if (!val && !isRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); @@ -179,7 +208,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "integer": @@ -190,9 +219,7 @@ const DynamicJsonForm = ({ value={(currentValue as number)?.toString() ?? ""} onChange={(e) => { const val = e.target.value; - // Allow clearing non-required integer fields - // This preserves the distinction between 0 and unset - if (!val && !propSchema.required) { + if (!val && !isRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); @@ -203,7 +230,7 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} /> ); case "boolean": @@ -213,9 +240,135 @@ const DynamicJsonForm = ({ checked={(currentValue as boolean) ?? false} onChange={(e) => handleFieldChange(path, e.target.checked)} className="w-4 h-4" - required={propSchema.required} + required={isRequired} /> ); + case "object": + if (!propSchema.properties) { + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; + + // If the array items are simple, render as form fields, otherwise use JSON editor + if (isSimpleObject(propSchema.items)) { + return ( +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + + // For complex arrays, fall back to JSON editor + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } default: return null; } diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 88d931c0..99774297 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -4,14 +4,17 @@ 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; } @@ -35,6 +38,21 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { 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); + // 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 @@ -50,8 +68,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } // Instead of storing in sessionStorage, pass the code directly - // to the auth state manager through onConnect - onConnect({ authorizationCode: params.code }); + // to the auth state manager through onConnect, along with restored state + onConnect({ authorizationCode: params.code, restoredState }); }; handleCallback().finally(() => { diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 396af142..0e260556 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -128,15 +128,86 @@ export const OAuthFlowProgress = ({ label="Metadata Discovery" {...getStepProps("metadata_discovery")} > - {provider.getServerMetadata() && ( + {authState.oauthMetadata && (
- Retrieved OAuth Metadata from {serverUrl} - /.well-known/oauth-authorization-server + OAuth Metadata Sources + {!authState.resourceMetadata && " ā„¹ļø"} -
-                {JSON.stringify(provider.getServerMetadata(), null, 2)}
-              
+ + {authState.resourceMetadata && ( +
+

Resource Metadata:

+

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

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

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

+

+ Resource metadata was added in the{" "} + + 2025-DRAFT-v2 specification update + +
+ {authState.resourceMetadataError.message} + {authState.resourceMetadataError instanceof TypeError && + " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} +

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

Authorization Server Metadata:

+ {authState.authServerUrl && ( +

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

+ )} +
+                    {JSON.stringify(authState.oauthMetadata, null, 2)}
+                  
+
+ )}
)} diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 35742799..8ec3f91b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -16,6 +16,7 @@ import ListPane from "./ListPane"; import { useEffect, useState } from "react"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; import JsonView from "./JsonView"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; const ResourcesTab = ({ resources, @@ -79,10 +80,7 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - return template.replace( - /{([^}]+)}/g, - (_, key) => values[key] || `{${key}}`, - ); + return new UriTemplate(template).expand(values); }; const handleTemplateValueChange = async (key: string, value: string) => { @@ -237,28 +235,27 @@ const ResourcesTab = ({

{selectedTemplate.description}

- {selectedTemplate.uriTemplate - .match(/{([^}]+)}/g) - ?.map((param) => { - const key = param.slice(1, -1); - return ( -
- - - handleTemplateValueChange(key, value) - } - onInputChange={(value) => - handleTemplateValueChange(key, value) - } - options={completions[key] || []} - /> -
- ); - })} + {new UriTemplate( + selectedTemplate.uriTemplate, + ).variableNames?.map((key) => { + return ( +
+ + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
+ ); + })} + ), +})); + +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 mockResourceTemplate: ResourceTemplate = { + name: "Users API", + uriTemplate: "test://users{?name,limit,offset}", + description: "Fetch users with optional filtering and pagination", + }; + + const mockResource: Resource = { + uri: "test://users?name=john&limit=10&offset=0", + name: "Users Resource", + description: "Expanded users resource", + }; + + const defaultProps = { + resources: [], + resourceTemplates: [mockResourceTemplate], + listResources: mockListResources, + clearResources: mockClearResources, + listResourceTemplates: mockListResourceTemplates, + clearResourceTemplates: mockClearResourceTemplates, + readResource: mockReadResource, + selectedResource: null, + setSelectedResource: mockSetSelectedResource, + handleCompletion: mockHandleCompletion, + completionsSupported: true, + resourceContent: "", + nextCursor: undefined, + nextTemplateCursor: undefined, + error: null, + resourceSubscriptionsSupported: false, + resourceSubscriptions: new Set(), + subscribeToResource: mockSubscribeToResource, + unsubscribeFromResource: mockUnsubscribeFromResource, + }; + + const renderResourcesTab = (props = {}) => + render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse and display template variables from URI template", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Check that input fields are rendered for each template variable + expect(screen.getByTestId("combobox-name")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-limit")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-offset")).toBeInTheDocument(); + }); + + it("should display template description when template is selected", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + expect( + screen.getByText("Fetch users with optional filtering and pagination"), + ).toBeInTheDocument(); + }); + + it("should handle template value changes", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Find and fill template value inputs + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + expect(nameInput).toHaveValue("john"); + expect(limitInput).toHaveValue("10"); + expect(offsetInput).toHaveValue("0"); + }); + + it("should expand template and read resource when Read Resource button is clicked", async () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + // Click Read Resource button + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Verify that readResource was called with the expanded URI + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john&limit=10&offset=0", + ); + + // Verify that setSelectedResource was called with the expanded resource + expect(mockSetSelectedResource).toHaveBeenCalledWith({ + uri: "test://users?name=john&limit=10&offset=0", + name: "test://users?name=john&limit=10&offset=0", + }); + }); + + it("should disable Read Resource button when no template values are provided", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Read Resource button should be disabled when no values are provided + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).toBeDisabled(); + }); + + it("should handle partial template values correctly", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill only some template values + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Read Resource button should be enabled with partial values + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Should expand with only the provided values + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john"); + }); + + it("should handle special characters in template values", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values with special characters + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john doe" } }); + + fireEvent.click(screen.getByText("Read Resource")); + + // Should properly encode special characters + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john%20doe", + ); + }); + + it("should clear template values when switching between templates", () => { + const anotherTemplate: ResourceTemplate = { + name: "Posts API", + uriTemplate: "test://posts{?author,category}", + description: "Fetch posts by author and category", + }; + + renderResourcesTab({ + resourceTemplates: [mockResourceTemplate, anotherTemplate], + }); + + // Select first template and fill values + fireEvent.click(screen.getByText("Users API")); + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Switch to second template + fireEvent.click(screen.getByText("Posts API")); + + // Should show new template fields and clear previous values + expect(screen.getByTestId("combobox-author")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-category")).toBeInTheDocument(); + expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument(); + }); + + it("should display resource content when a resource is selected", () => { + const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; + + renderResourcesTab({ + selectedResource: mockResource, + resourceContent: resourceContent, + }); + + expect(screen.getByTestId("json-view")).toBeInTheDocument(); + expect(screen.getByText(resourceContent)).toBeInTheDocument(); + }); + + it("should show alert when no resource or template is selected", () => { + renderResourcesTab(); + + expect( + screen.getByText( + "Select a resource or template from the list to view its contents", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index ba03d180..d818bdbb 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -519,6 +519,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 5000, + is_session_item: false, }, }), ); @@ -544,6 +545,7 @@ describe("Sidebar Environment Variables", () => { 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, }, }), ); @@ -569,6 +571,7 @@ describe("Sidebar Environment Variables", () => { description: "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", value: 10000, + is_session_item: false, }, }), ); @@ -591,6 +594,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 0, + is_session_item: false, }, }), ); @@ -637,6 +641,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 3000, + is_session_item: false, }, }), ); diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index c9f9b315..dc353ba5 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -111,6 +111,27 @@ describe("ToolsTab", () => { }); }); + it("should allow typing negative numbers", async () => { + renderToolsTab({ + selectedTool: mockTools[0], + }); + + const input = screen.getByRole("spinbutton") as HTMLInputElement; + + // Complete the negative number + fireEvent.change(input, { target: { value: "-42" } }); + expect(input.value).toBe("-42"); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, { + num: -42, + }); + }); + it("should disable button and change text while tool is running", async () => { // Create a promise that we can resolve later let resolvePromise: ((value: unknown) => void) | undefined; diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index ef32601a..f8f927c6 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -3,6 +3,7 @@ import { OAuthClientInformationFull, OAuthClientInformation, OAuthTokens, + OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; // OAuth flow steps @@ -26,8 +27,11 @@ export interface StatusMessage { export interface AuthDebuggerState { isInitiatingAuth: boolean; oauthTokens: OAuthTokens | null; - loading: boolean; oauthStep: OAuthStep; + resourceMetadata: OAuthProtectedResourceMetadata | null; + resourceMetadataError: Error | null; + resource: URL | null; + authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; authorizationUrl: string | null; @@ -36,3 +40,20 @@ export interface AuthDebuggerState { 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/configurationTypes.ts b/client/src/lib/configurationTypes.ts index 7c74bb71..263b2cf2 100644 --- a/client/src/lib/configurationTypes.ts +++ b/client/src/lib/configurationTypes.ts @@ -2,6 +2,7 @@ export type ConfigItem = { label: string; description: string; value: string | number | boolean; + is_session_item: boolean; }; /** @@ -33,4 +34,9 @@ export type InspectorConfig = { * 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 */ 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; }; diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 4c3e27aa..922f1943 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -7,6 +7,7 @@ export const SESSION_KEYS = { TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", SERVER_METADATA: "mcp_server_metadata", + AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", } as const; // Generate server-specific session storage keys @@ -35,22 +36,33 @@ export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 10000, + is_session_item: false, }, MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: { label: "Reset Timeout on Progress", description: "Reset timeout on progress notifications", value: true, + is_session_item: false, }, MCP_REQUEST_MAX_TOTAL_TIMEOUT: { label: "Maximum Total Timeout", description: "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", 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", + description: + "Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)", + value: "", + is_session_item: true, }, } as const; diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 6e5748d7..676ae87d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -3,6 +3,7 @@ import { useConnection } from "../useConnection"; import { z } from "zod"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; +import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ @@ -23,21 +24,46 @@ const mockClient = { setRequestHandler: jest.fn(), }; +// Mock transport instances +const mockSSETransport: { + start: jest.Mock; + url: URL | undefined; + options: SSEClientTransportOptions | undefined; +} = { + start: jest.fn(), + url: undefined, + options: 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", () => ({ - SSEClientTransport: jest.fn((url) => ({ - toString: () => url, - })), + SSEClientTransport: jest.fn((url, options) => { + mockSSETransport.url = url; + mockSSETransport.options = options; + return mockSSETransport; + }), SseError: jest.fn(), })); jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: jest.fn((url) => ({ - toString: () => url, - })), + StreamableHTTPClientTransport: jest.fn((url, options) => { + mockStreamableHTTPTransport.url = url; + mockStreamableHTTPTransport.options = options; + return mockStreamableHTTPTransport; + }), })); jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ @@ -259,4 +285,181 @@ describe("useConnection", () => { ); }); }); + + 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", async () => { + 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(); + }); + + // 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 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 propsWithBothAuth = { + ...defaultProps, + bearerToken: "server-auth-token", + 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"); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9c825318..9009e698 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -42,9 +42,11 @@ import { getMCPProxyAddress, getMCPServerRequestMaxTotalTimeout, resetRequestTimeoutOnProgress, + getMCPProxyAuthToken, } from "@/utils/configUtils"; import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; interface UseConnectionOptions { transportType: "stdio" | "sse" | "streamable-http"; @@ -83,6 +85,9 @@ export function useConnection({ const [serverCapabilities, setServerCapabilities] = useState(null); const [mcpClient, setMcpClient] = useState(null); + const [clientTransport, setClientTransport] = useState( + null, + ); const [requestHistory, setRequestHistory] = useState< { request: string; response?: string }[] >([]); @@ -238,7 +243,13 @@ export function useConnection({ const checkProxyHealth = async () => { try { const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`); - const proxyHealthResponse = await fetch(proxyHealthUrl); + 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"); @@ -257,6 +268,13 @@ export function useConnection({ ); }; + 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)) { const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); @@ -304,7 +322,22 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - headers[authHeaderName] = `Bearer ${token}`; + + // Add custom header name as a special request header to let the server know which header to pass through + if (authHeaderName.toLowerCase() !== "authorization") { + headers[authHeaderName] = token; + headers["x-custom-auth-header"] = authHeaderName; + } else { + headers[authHeaderName] = `Bearer ${token}`; + } + } + + // Add proxy authentication + const { token: proxyAuthToken, header: proxyAuthTokenHeader } = + getMCPProxyAuthToken(config); + const proxyHeaders: HeadersInit = {}; + if (proxyAuthToken) { + proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; } // Create appropriate transport @@ -324,11 +357,15 @@ export function useConnection({ eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, - init: RequestInit | undefined, - ) => fetch(url, { ...init, headers }), + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { - headers, + headers: { ...headers, ...proxyHeaders }, }, }; break; @@ -340,11 +377,15 @@ export function useConnection({ eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, - init: RequestInit | undefined, - ) => fetch(url, { ...init, headers }), + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { - headers, + headers: { ...headers, ...proxyHeaders }, }, }; break; @@ -356,11 +397,15 @@ export function useConnection({ eventSourceInit: { fetch: ( url: string | URL | globalThis.Request, - init: RequestInit | undefined, - ) => fetch(url, { ...init, headers }), + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { - headers, + headers: { ...headers, ...proxyHeaders }, }, // TODO these should be configurable... reconnectionOptions: { @@ -377,14 +422,6 @@ export function useConnection({ transportType, ); - const clientTransport = - transportType === "streamable-http" - ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { - sessionId: undefined, - ...transportOptions, - }) - : new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions); - if (onNotification) { [ CancelledNotificationSchema, @@ -414,7 +451,20 @@ export function useConnection({ let capabilities; try { - await client.connect(clientTransport); + const transport = + transportType === "streamable-http" + ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + sessionId: undefined, + ...transportOptions, + }) + : new SSEClientTransport( + mcpProxyServerUrl as URL, + transportOptions, + ); + + await client.connect(transport as Transport); + + setClientTransport(transport); capabilities = client.getServerCapabilities(); const initializeRequest = { @@ -431,6 +481,18 @@ export function useConnection({ 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); @@ -468,10 +530,15 @@ export function useConnection({ }; const disconnect = async () => { + 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); diff --git a/client/src/lib/hooks/useDraggablePane.ts b/client/src/lib/hooks/useDraggablePane.ts index 5c0e3573..4ee5af54 100644 --- a/client/src/lib/hooks/useDraggablePane.ts +++ b/client/src/lib/hooks/useDraggablePane.ts @@ -51,3 +51,55 @@ export function useDraggablePane(initialHeight: number) { handleDragStart, }; } + +export function useDraggableSidebar(initialWidth: number) { + const [width, setWidth] = useState(initialWidth); + const [isDragging, setIsDragging] = useState(false); + const dragStartX = useRef(0); + const dragStartWidth = useRef(0); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + setIsDragging(true); + dragStartX.current = e.clientX; + dragStartWidth.current = width; + document.body.style.userSelect = "none"; + }, + [width], + ); + + const handleDragMove = useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + const deltaX = e.clientX - dragStartX.current; + const newWidth = Math.max( + 200, + Math.min(600, dragStartWidth.current + deltaX), + ); + setWidth(newWidth); + }, + [isDragging], + ); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + return { + width, + isDragging, + handleDragStart, + }; +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 0678229c..d87b3ecd 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -5,8 +5,13 @@ import { registerClient, startAuthorization, exchangeAuthorization, + discoverOAuthProtectedResourceMetadata, + selectResourceURL, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthMetadataSchema, + OAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { state: AuthDebuggerState; @@ -18,7 +23,6 @@ export interface StateMachineContext { export interface StateTransition { canTransition: (context: StateMachineContext) => Promise; execute: (context: StateMachineContext) => Promise; - nextStep: OAuthStep; } // State machine transitions @@ -26,18 +30,47 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - const metadata = await discoverOAuthMetadata(context.serverUrl); + // Default to discovering from the server's URL + let authServerUrl = new URL("/", context.serverUrl); + let resourceMetadata: OAuthProtectedResourceMetadata | null = null; + let resourceMetadataError: Error | null = null; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + context.serverUrl, + ); + if (resourceMetadata?.authorization_servers?.length) { + authServerUrl = new URL(resourceMetadata.authorization_servers[0]); + } + } catch (e) { + if (e instanceof Error) { + resourceMetadataError = e; + } else { + resourceMetadataError = new Error(String(e)); + } + } + + 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 metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); context.provider.saveServerMetadata(parsedMetadata); context.updateState({ + resourceMetadata, + resource, + resourceMetadataError, + authServerUrl, oauthMetadata: parsedMetadata, oauthStep: "client_registration", }); }, - nextStep: "client_registration", }, client_registration: { @@ -46,9 +79,13 @@ export const oauthTransitions: Record = { const metadata = context.state.oauthMetadata!; const clientMetadata = context.provider.clientMetadata; + // Prefer scopes from resource metadata if available + const scopesSupported = + context.state.resourceMetadata?.scopes_supported || + metadata.scopes_supported; // Add all supported scopes to client registration - if (metadata.scopes_supported) { - clientMetadata.scope = metadata.scopes_supported.join(" "); + if (scopesSupported) { + clientMetadata.scope = scopesSupported.join(" "); } const fullInformation = await registerClient(context.serverUrl, { @@ -62,7 +99,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_redirect", }); }, - nextStep: "authorization_redirect", }, authorization_redirect: { @@ -84,6 +120,7 @@ export const oauthTransitions: Record = { clientInformation, redirectUrl: context.provider.redirectUrl, scope, + resource: context.state.resource ?? undefined, }, ); @@ -93,7 +130,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_code", }); }, - nextStep: "authorization_code", }, authorization_code: { @@ -114,7 +150,6 @@ export const oauthTransitions: Record = { oauthStep: "token_request", }); }, - nextStep: "token_request", }, token_request: { @@ -136,6 +171,7 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, + resource: context.state.resource ?? undefined, }); context.provider.saveTokens(tokens); @@ -144,7 +180,6 @@ export const oauthTransitions: Record = { oauthStep: "complete", }); }, - nextStep: "complete", }, complete: { @@ -152,7 +187,6 @@ export const oauthTransitions: Record = { execute: async () => { // No-op for complete state }, - nextStep: "complete", }, }; diff --git a/client/src/utils/__tests__/configUtils.test.ts b/client/src/utils/__tests__/configUtils.test.ts new file mode 100644 index 00000000..86dccbdf --- /dev/null +++ b/client/src/utils/__tests__/configUtils.test.ts @@ -0,0 +1,72 @@ +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/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index ca6b65ca..133e25ec 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -11,72 +11,68 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; describe("generateDefaultValue", () => { test("generates default string", () => { - expect(generateDefaultValue({ type: "string", required: true })).toBe(""); + const parentSchema = { type: "object" as const, required: ["testProp"] }; + expect( + generateDefaultValue({ type: "string" }, "testProp", parentSchema), + ).toBe(""); }); test("generates default number", () => { - expect(generateDefaultValue({ type: "number", required: true })).toBe(0); + const parentSchema = { type: "object" as const, required: ["testProp"] }; + expect( + generateDefaultValue({ type: "number" }, "testProp", parentSchema), + ).toBe(0); }); test("generates default integer", () => { - expect(generateDefaultValue({ type: "integer", required: true })).toBe(0); + const parentSchema = { type: "object" as const, required: ["testProp"] }; + expect( + generateDefaultValue({ type: "integer" }, "testProp", parentSchema), + ).toBe(0); }); test("generates default boolean", () => { - expect(generateDefaultValue({ type: "boolean", required: true })).toBe( - false, - ); + const parentSchema = { type: "object" as const, required: ["testProp"] }; + expect( + generateDefaultValue({ type: "boolean" }, "testProp", parentSchema), + ).toBe(false); }); test("generates default array", () => { - expect(generateDefaultValue({ type: "array", required: true })).toEqual([]); + expect(generateDefaultValue({ type: "array" })).toEqual([]); }); test("generates default empty object", () => { - expect(generateDefaultValue({ type: "object", required: true })).toEqual( - {}, - ); + expect(generateDefaultValue({ type: "object" })).toEqual({}); }); test("generates default null for unknown types", () => { // @ts-expect-error Testing with invalid type - expect(generateDefaultValue({ type: "unknown", required: true })).toBe( - null, - ); + expect(generateDefaultValue({ type: "unknown" })).toBe(undefined); }); test("generates empty array for non-required array", () => { - expect(generateDefaultValue({ type: "array", required: false })).toEqual( - [], - ); + expect(generateDefaultValue({ type: "array" })).toEqual([]); }); test("generates empty object for non-required object", () => { - expect(generateDefaultValue({ type: "object", required: false })).toEqual( - {}, - ); + expect(generateDefaultValue({ type: "object" })).toEqual({}); }); - test("generates null for non-required primitive types", () => { - expect(generateDefaultValue({ type: "string", required: false })).toBe( - undefined, - ); - expect(generateDefaultValue({ type: "number", required: false })).toBe( - undefined, - ); - expect(generateDefaultValue({ type: "boolean", required: false })).toBe( - undefined, - ); + test("generates undefined for non-required primitive types", () => { + expect(generateDefaultValue({ type: "string" })).toBe(undefined); + expect(generateDefaultValue({ type: "number" })).toBe(undefined); + expect(generateDefaultValue({ type: "boolean" })).toBe(undefined); }); test("generates object with properties", () => { const schema: JsonSchemaType = { type: "object", - required: true, + required: ["name", "age", "isActive"], properties: { - name: { type: "string", required: true }, - age: { type: "number", required: true }, - isActive: { type: "boolean", required: true }, + name: { type: "string" }, + age: { type: "number" }, + isActive: { type: "boolean" }, }, }; expect(generateDefaultValue(schema)).toEqual({ @@ -89,18 +85,18 @@ describe("generateDefaultValue", () => { test("handles nested objects", () => { const schema: JsonSchemaType = { type: "object", - required: true, + required: ["user"], properties: { user: { type: "object", - required: true, + required: ["name", "address"], properties: { - name: { type: "string", required: true }, + name: { type: "string" }, address: { type: "object", - required: true, + required: ["city"], properties: { - city: { type: "string", required: true }, + city: { type: "string" }, }, }, }, diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index ae404d65..85319497 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -4,12 +4,26 @@ import { DEFAULT_INSPECTOR_CONFIG, } from "@/lib/constants"; +const getSearchParam = (key: string): string | null => { + try { + const url = new URL(window.location.href); + return url.searchParams.get(key); + } catch { + return null; + } +}; + export const getMCPProxyAddress = (config: InspectorConfig): string => { const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; if (proxyFullAddress) { return proxyFullAddress; } - return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`; + + // 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 => { @@ -28,13 +42,16 @@ export const getMCPServerRequestMaxTotalTimeout = ( return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number; }; -const getSearchParam = (key: string): string | null => { - try { - const url = new URL(window.location.href); - return url.searchParams.get(key); - } catch { - return null; - } +export const getMCPProxyAuthToken = ( + config: InspectorConfig, +): { + token: string; + header: string; +} => { + return { + token: config.MCP_PROXY_AUTH_TOKEN.value as string, + header: "X-MCP-Proxy-Auth", + }; }; export const getInitialTransportType = (): @@ -100,27 +117,67 @@ export const getConfigOverridesFromQueryParams = ( export const initializeInspectorConfig = ( localStorageKey: string, ): InspectorConfig => { - const savedConfig = localStorage.getItem(localStorageKey); - let baseConfig: InspectorConfig; - if (savedConfig) { - // merge default config with saved config - const mergedConfig = { - ...DEFAULT_INSPECTOR_CONFIG, - ...JSON.parse(savedConfig), - } as InspectorConfig; + // Read persistent config from localStorage + const savedPersistentConfig = localStorage.getItem(localStorageKey); + // Read ephemeral config from sessionStorage + const savedEphemeralConfig = sessionStorage.getItem( + `${localStorageKey}_ephemeral`, + ); - // update description of keys to match the new description (in case of any updates to the default config description) - for (const [key, value] of Object.entries(mergedConfig)) { - mergedConfig[key as keyof InspectorConfig] = { - ...value, - label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, - }; - } - baseConfig = mergedConfig; - } else { - baseConfig = DEFAULT_INSPECTOR_CONFIG; + // Start with default config + let baseConfig = { ...DEFAULT_INSPECTOR_CONFIG }; + + // Apply saved persistent config + if (savedPersistentConfig) { + const parsedPersistentConfig = JSON.parse(savedPersistentConfig); + baseConfig = { ...baseConfig, ...parsedPersistentConfig }; } + + // Apply saved ephemeral config + if (savedEphemeralConfig) { + const parsedEphemeralConfig = JSON.parse(savedEphemeralConfig); + baseConfig = { ...baseConfig, ...parsedEphemeralConfig }; + } + + // Ensure all config items have the latest labels/descriptions from defaults + for (const [key, value] of Object.entries(baseConfig)) { + baseConfig[key as keyof InspectorConfig] = { + ...value, + label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, + description: + DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].description, + is_session_item: + DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].is_session_item, + }; + } + // Apply query param overrides const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG); return { ...baseConfig, ...overrides }; }; + +export const saveInspectorConfig = ( + localStorageKey: string, + config: InspectorConfig, +): void => { + const persistentConfig: Partial = {}; + const ephemeralConfig: Partial = {}; + + // Split config based on is_session_item flag + for (const [key, value] of Object.entries(config)) { + if (value.is_session_item) { + ephemeralConfig[key as keyof InspectorConfig] = value; + } else { + persistentConfig[key as keyof InspectorConfig] = value; + } + } + + // Save persistent config to localStorage + localStorage.setItem(localStorageKey, JSON.stringify(persistentConfig)); + + // Save ephemeral config to sessionStorage + sessionStorage.setItem( + `${localStorageKey}_ephemeral`, + JSON.stringify(ephemeralConfig), + ); +}; diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 28cbf303..83317938 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -17,7 +17,7 @@ export type JsonSchemaType = { | "object" | "null"; description?: string; - required?: boolean; + required?: string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 30210532..9fc7a724 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -81,46 +81,70 @@ export function hasOutputSchema(toolName: string): boolean { /** * Generates a default value based on a JSON schema type * @param schema The JSON schema definition - * @returns A default value matching the schema type, or null for non-required fields + * @param propertyName Optional property name for checking if it's required in parent schema + * @param parentSchema Optional parent schema to check required array + * @returns A default value matching the schema type */ -export function generateDefaultValue(schema: JsonSchemaType): JsonValue { - if ("default" in schema) { +export function generateDefaultValue( + schema: JsonSchemaType, + propertyName?: string, + parentSchema?: JsonSchemaType, +): JsonValue { + if ("default" in schema && schema.default !== undefined) { return schema.default; } - if (!schema.required) { - if (schema.type === "array") return []; - if (schema.type === "object") return {}; - return undefined; - } + // Check if this property is required in the parent schema + const isRequired = + propertyName && parentSchema + ? isPropertyRequired(propertyName, parentSchema) + : false; switch (schema.type) { case "string": - return ""; + return isRequired ? "" : undefined; case "number": case "integer": - return 0; + return isRequired ? 0 : undefined; case "boolean": - return false; + return isRequired ? false : undefined; case "array": return []; case "object": { if (!schema.properties) return {}; const obj: JsonObject = {}; - Object.entries(schema.properties) - .filter(([, prop]) => prop.required) - .forEach(([key, prop]) => { - const value = generateDefaultValue(prop); - obj[key] = value; - }); + // Only include properties that are required according to the schema's required array + Object.entries(schema.properties).forEach(([key, prop]) => { + if (isPropertyRequired(key, schema)) { + const value = generateDefaultValue(prop, key, schema); + if (value !== undefined) { + obj[key] = value; + } + } + }); return obj; } - default: + case "null": return null; + default: + return undefined; } } +/** + * Helper function to check if a property is required in a schema + * @param propertyName The name of the property to check + * @param schema The parent schema containing the required array + * @returns true if the property is required, false otherwise + */ +export function isPropertyRequired( + propertyName: string, + schema: JsonSchemaType, +): boolean { + return schema.required?.includes(propertyName) ?? false; +} + /** * Formats a field key into a human-readable label * @param key The field key to format diff --git a/package-lock.json b/package-lock.json index 2920ca59..ce3b7ba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.15.0", "license": "MIT", "workspaces": [ "client", @@ -14,10 +14,10 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.13.0", - "@modelcontextprotocol/inspector-client": "^0.13.0", - "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/inspector-cli": "^0.15.0", + "@modelcontextprotocol/inspector-client": "^0.15.0", + "@modelcontextprotocol/inspector-server": "^0.15.0", + "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -29,6 +29,7 @@ "mcp-inspector": "cli/build/cli.js" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", @@ -40,10 +41,10 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.13.0", + "version": "0.15.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -63,10 +64,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.13.0", + "version": "0.15.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -81,7 +82,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", - "lucide-react": "^0.447.0", + "lucide-react": "^0.523.0", "pkce-challenge": "^4.1.0", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -2005,12 +2006,11 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.5.tgz", - "integrity": "sha512-gS7Q7IHpKxjVaNLMUZyTtatZ63ca3h418zPPntAhu/MvG5yfz/8HMcDAOpvpQfx3V3dsw9QQxk8RuFNrQhLlgA==", - "license": "MIT", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.1.tgz", + "integrity": "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==", "dependencies": { - "ajv": "^8.17.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -2026,28 +2026,6 @@ "node": ">=18" } }, - "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==", - "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" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2093,6 +2071,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2106,12 +2100,12 @@ "license": "MIT" }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz", - "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", + "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.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2129,16 +2123,16 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.2.3.tgz", - "integrity": "sha512-pHVzDYsnaDmBlAuwim45y3soIN8H4R7KbkSVirGhXO+R/kO2OLCe0eucUEbddaTcdMHHdzcIGHtZSMSQlA+apw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", + "@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" @@ -2159,15 +2153,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", - "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "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.0", - "@radix-ui/react-slot": "1.2.0" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2215,22 +2209,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.11.tgz", - "integrity": "sha512-yI7S1ipkP5/+99qhSI6nthfo/tR6bL6Zgxi/+1UO6qPa6UeM6nlafWcQ65vB4rU2XjgjMfMhI3k9Y5MztA62VQ==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0", + "@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" @@ -2266,14 +2260,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.7.tgz", - "integrity": "sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.0", + "@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" }, @@ -2308,13 +2302,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.4.tgz", - "integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==", + "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.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { @@ -2360,12 +2354,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz", - "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2383,23 +2377,23 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.11.tgz", - "integrity": "sha512-yFMfZkVA5G3GJnBgb2PxrrcLKm1ZLWXrbYVgdyTl//0TYEIHS9LJbnyz7WWcZ0qCq7hIlJZpRtxeSeIG5T5oJw==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.4", - "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0", + "@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" @@ -2420,16 +2414,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.4.tgz", - "integrity": "sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.4", + "@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.0", + "@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", @@ -2452,12 +2446,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.6.tgz", - "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", + "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.0", + "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -2500,12 +2494,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", - "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "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.0" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2523,18 +2517,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.7.tgz", - "integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.4", + "@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.0", + "@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" }, @@ -2554,30 +2548,30 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.2.tgz", - "integrity": "sha512-HjkVHtBkuq+r3zUAZ/CvNWUGKPfuicGDbgtZgiQuFmNcV5F+Tgy24ep2nsAW2nFgvhGPJVqeBZa6KyVN0EyrBA==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.4", + "@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.7", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.4", - "@radix-ui/react-portal": "1.1.6", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-popper": "1.2.7", + "@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.0", + "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, @@ -2597,9 +2591,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "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" @@ -2615,9 +2609,9 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.9.tgz", - "integrity": "sha512-KIjtwciYvquiW/wAFkELZCVnaNLBsYNhTNcvl+zfMAbMhRkcvNuCLXDDd22L0j7tagpzVh/QwbFpwAATg7ILPw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", @@ -2625,8 +2619,8 @@ "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-roving-focus": "1.1.7", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { @@ -2645,23 +2639,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.11.tgz", - "integrity": "sha512-Ed2mlOmT+tktOsu2NZBK1bCSHh/uqULu1vWOkpQTVq53EoOuZUZw7FInQoDB3uil5wZc2oe0XN9a7uVZB7/6AQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.4", + "@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.7", - "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", + "@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.0" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2679,23 +2673,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.4.tgz", - "integrity": "sha512-DyW8VVeeMSSLFvAmnVnCwvI3H+1tpJFHT50r+tdOoMse9XqYDBCcyux8u3G2y+LOpt7fPQ6KKH0mhs+ce1+Z5w==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.7", + "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.4", - "@radix-ui/react-portal": "1.1.6", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.0", - "@radix-ui/react-slot": "1.2.0", + "@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.0" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2849,12 +2843,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.0.tgz", - "integrity": "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==", + "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.0" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -5719,22 +5713,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "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", @@ -7882,12 +7860,12 @@ } }, "node_modules/lucide-react": { - "version": "0.447.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.447.0.tgz", - "integrity": "sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==", + "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-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/lz-string": { @@ -8558,6 +8536,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -9064,15 +9089,6 @@ "node": ">=0.10.0" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -10968,10 +10984,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.13.0", + "version": "0.15.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index d46b3ef8..de02890d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.13.0", + "version": "0.15.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,23 +27,27 @@ "build-client": "cd client && 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": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"", - "dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"", + "dev": "node client/bin/start.js --dev", + "dev:windows": "node client/bin/start.js --dev", "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", "test-cli": "cd cli && npm run test", + "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", + "lint": "prettier --check . && cd client && npm run lint", "prepare": "npm run build", - "publish-all": "npm publish --workspaces --access public && npm publish --access public" + "publish-all": "npm publish --workspaces --access public && npm publish --access public", + "update-version": "node scripts/update-version.js", + "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.13.0", - "@modelcontextprotocol/inspector-client": "^0.13.0", - "@modelcontextprotocol/inspector-server": "^0.13.0", - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/inspector-cli": "^0.15.0", + "@modelcontextprotocol/inspector-client": "^0.15.0", + "@modelcontextprotocol/inspector-server": "^0.15.0", + "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -52,6 +56,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..d6887faf --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,72 @@ +# Version Management Scripts + +This directory contains scripts for managing version consistency across the monorepo. + +## Scripts + +### update-version.js + +Updates the version across all package.json files in the monorepo and updates package-lock.json. + +**Usage:** + +```bash +npm run update-version +# Example: +npm run update-version 0.14.3 +``` + +This script will: + +1. Update the version in all package.json files (root, client, server, cli) +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 + +### check-version-consistency.js + +Checks that all packages have consistent versions and that package-lock.json is up to date. + +**Usage:** + +```bash +npm run check-version +``` + +This script checks: + +1. All package.json files have the same version +2. Workspace dependencies in root package.json match the current version +3. package-lock.json version matches package.json +4. Workspace packages in package-lock.json have the correct versions + +This check runs automatically in CI on every PR and push to main. + +## CI Integration + +The version consistency check is integrated into the GitHub Actions workflow (`.github/workflows/main.yml`) and will fail the build if: + +- Package versions are inconsistent +- package-lock.json is out of sync + +## Common Workflows + +### Bumping version for a release: + +```bash +# Update to new version +npm run update-version 0.15.0 + +# Verify everything is correct +npm run check-version + +# Commit the changes +git add -A +git commit -m "chore: bump version to 0.15.0" + +# Create a tag +git tag 0.15.0 + +# Push changes and tag +git push && git push --tags +``` diff --git a/scripts/check-version-consistency.js b/scripts/check-version-consistency.js new file mode 100755 index 00000000..379931de --- /dev/null +++ b/scripts/check-version-consistency.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Checks version consistency across all package.json files in the monorepo + * Exits with code 1 if versions are inconsistent + * Usage: node scripts/check-version-consistency.js + */ + +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", + "cli/package.json", +]; + +const versions = new Map(); +const errors = []; + +// Read version from each package.json +packagePaths.forEach((packagePath) => { + const fullPath = path.join(__dirname, "..", packagePath); + + if (!fs.existsSync(fullPath)) { + console.warn(`āš ļø Skipping ${packagePath} - file not found`); + return; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(fullPath, "utf8")); + const version = packageJson.version; + const packageName = packageJson.name || packagePath; + + versions.set(packagePath, { + name: packageName, + version: version, + dependencies: packageJson.dependencies || {}, + }); + + console.log(`šŸ“¦ ${packagePath}:`); + console.log(` Name: ${packageName}`); + console.log(` Version: ${version}`); + } catch (error) { + errors.push(`Failed to read ${packagePath}: ${error.message}`); + } +}); + +if (errors.length > 0) { + console.error("\nāŒ Errors occurred while reading package files:"); + errors.forEach((error) => console.error(` - ${error}`)); + process.exit(1); +} + +// Check if all versions match +const allVersions = Array.from(versions.values()).map((v) => v.version); +const uniqueVersions = [...new Set(allVersions)]; + +console.log("\nšŸ“Š Version Summary:"); +console.log(` Total packages: ${versions.size}`); +console.log(` Unique versions: ${uniqueVersions.length}`); + +if (uniqueVersions.length > 1) { + console.error("\nāŒ Version mismatch detected!"); + console.error(" Found versions: " + uniqueVersions.join(", ")); + + console.error("\n Package versions:"); + versions.forEach((info, path) => { + console.error(` - ${path}: ${info.version}`); + }); +} else { + console.log(` āœ… All packages are at version: ${uniqueVersions[0]}`); +} + +// Check workspace dependencies in root package.json +const rootPackage = versions.get("package.json"); +if (rootPackage) { + console.log("\nšŸ”— Checking workspace dependencies..."); + const expectedVersion = rootPackage.version; + let dependencyErrors = false; + + Object.entries(rootPackage.dependencies).forEach(([dep, version]) => { + if (dep.startsWith("@modelcontextprotocol/inspector-")) { + const expectedDepVersion = `^${expectedVersion}`; + if (version !== expectedDepVersion) { + console.error( + ` āŒ ${dep}: ${version} (expected ${expectedDepVersion})`, + ); + dependencyErrors = true; + } else { + console.log(` āœ… ${dep}: ${version}`); + } + } + }); + + if (dependencyErrors) { + errors.push("Workspace dependency versions do not match package versions"); + } +} + +// Check if package-lock.json is up to date +console.log("\nšŸ”’ Checking package-lock.json..."); +const lockPath = path.join(__dirname, "..", "package-lock.json"); +let lockFileError = false; + +if (!fs.existsSync(lockPath)) { + console.error(" āŒ package-lock.json not found"); + lockFileError = true; +} else { + try { + const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8")); + const lockVersion = lockFile.version; + const expectedVersion = rootPackage?.version || uniqueVersions[0]; + + if (lockVersion !== expectedVersion) { + console.error( + ` āŒ package-lock.json version (${lockVersion}) does not match package.json version (${expectedVersion})`, + ); + lockFileError = true; + } else { + console.log(` āœ… package-lock.json version matches: ${lockVersion}`); + } + + // 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: "cli", name: "@modelcontextprotocol/inspector-cli" }, + ]; + + workspacePackages.forEach(({ path, name }) => { + const lockPkgPath = lockFile.packages[path]; + if (lockPkgPath && lockPkgPath.version !== expectedVersion) { + console.error( + ` āŒ ${name} in lock file: ${lockPkgPath.version} (expected ${expectedVersion})`, + ); + lockFileError = true; + } + }); + } + } catch (error) { + console.error(` āŒ Failed to parse package-lock.json: ${error.message}`); + lockFileError = true; + } +} + +// Final result +console.log("\nšŸŽÆ Result:"); +if (uniqueVersions.length === 1 && errors.length === 0 && !lockFileError) { + console.log(" āœ… Version consistency check passed!"); + process.exit(0); +} else { + console.error(" āŒ Version consistency check failed!"); + if (uniqueVersions.length > 1) { + console.error(" - Package versions are not consistent"); + } + if (errors.length > 0) { + console.error(" - " + errors.join("\n - ")); + } + if (lockFileError) { + console.error(" - package-lock.json is out of sync"); + } + console.error( + '\nšŸ’” Run "npm run update-version " to fix version inconsistencies', + ); + console.error(' or run "npm install" to update package-lock.json'); + process.exit(1); +} diff --git a/scripts/update-version.js b/scripts/update-version.js new file mode 100755 index 00000000..91b69f3b --- /dev/null +++ b/scripts/update-version.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Updates version across all package.json files in the monorepo + * Usage: node scripts/update-version.js + * Example: node scripts/update-version.js 0.14.2 + */ + +const newVersion = process.argv[2]; + +if (!newVersion) { + console.error("āŒ Please provide a version number"); + console.error("Usage: node scripts/update-version.js "); + console.error("Example: node scripts/update-version.js 0.14.2"); + process.exit(1); +} + +// Validate version format +const versionRegex = /^\d+\.\d+\.\d+(-[\w.]+)?$/; +if (!versionRegex.test(newVersion)) { + console.error( + "āŒ Invalid version format. Please use semantic versioning (e.g., 1.2.3 or 1.2.3-beta.1)", + ); + process.exit(1); +} + +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", + "cli/package.json", +]; + +const updatedFiles = []; + +// Update version in each package.json +packagePaths.forEach((packagePath) => { + const fullPath = path.join(__dirname, "..", packagePath); + + if (!fs.existsSync(fullPath)) { + console.warn(`āš ļø Skipping ${packagePath} - file not found`); + return; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(fullPath, "utf8")); + const oldVersion = packageJson.version; + packageJson.version = newVersion; + + // Update workspace dependencies in root package.json + if (packagePath === "package.json" && packageJson.dependencies) { + Object.keys(packageJson.dependencies).forEach((dep) => { + if (dep.startsWith("@modelcontextprotocol/inspector-")) { + packageJson.dependencies[dep] = `^${newVersion}`; + } + }); + } + + fs.writeFileSync(fullPath, JSON.stringify(packageJson, null, 2) + "\n"); + updatedFiles.push(packagePath); + console.log( + `āœ… Updated ${packagePath} from ${oldVersion} to ${newVersion}`, + ); + } catch (error) { + console.error(`āŒ Failed to update ${packagePath}:`, error.message); + process.exit(1); + } +}); + +console.log("\nšŸ“ Summary:"); +console.log(`Updated ${updatedFiles.length} files to version ${newVersion}`); + +// Update package-lock.json +console.log("\nšŸ”’ Updating package-lock.json..."); +try { + execSync("npm install", { stdio: "inherit" }); + console.log("āœ… package-lock.json updated successfully"); +} catch (error) { + console.error("āŒ Failed to update package-lock.json:", error.message); + console.error('Please run "npm install" manually'); + process.exit(1); +} + +console.log("\n✨ Version update complete!"); +console.log("\nNext steps:"); +console.log("1. Review the changes: git diff"); +console.log( + '2. Commit the changes: git add -A && git commit -m "chore: bump version to ' + + newVersion + + '"', +); +console.log("3. Create a git tag: git tag v" + newVersion); +console.log("4. Push changes and tag: git push && git push --tags"); diff --git a/server/package.json b/server/package.json index a3333da0..b43fc289 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.13.0", + "version": "0.15.0", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.5", + "@modelcontextprotocol/sdk": "^1.13.1", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..971cf158 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -19,8 +19,9 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; -import { randomUUID } from "node:crypto"; +import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; +const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277"; const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [ "authorization", @@ -41,6 +42,44 @@ const { values } = parseArgs({ }, }); +// Function to get HTTP headers. +// Supports only "sse" and "streamable-http" transport types. +const getHttpHeaders = ( + req: express.Request, + transportType: string, +): HeadersInit => { + const headers: HeadersInit = { + Accept: + transportType === "sse" + ? "text/event-stream" + : "text/event-stream, application/json", + }; + const defaultHeaders = + transportType === "sse" + ? SSE_HEADERS_PASSTHROUGH + : STREAMABLE_HTTP_HEADERS_PASSTHROUGH; + + for (const key of defaultHeaders) { + if (req.headers[key] === undefined) { + continue; + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + const lowerCaseHeaderName = customHeaderName.toLowerCase(); + if (req.headers[lowerCaseHeaderName] !== undefined) { + const value = req.headers[lowerCaseHeaderName]; + headers[customHeaderName] = value as string; + } + } + return headers; +}; + const app = express(); app.use(cors()); app.use((req, res, next) => { @@ -48,11 +87,93 @@ app.use((req, res, next) => { next(); }); -const webAppTransports: Map = new Map(); // Transports by sessionId +const webAppTransports: Map = new Map(); // Web app transports by web app sessionId +const serverTransports: Map = new Map(); // Server Transports by web app sessionId + +// Use provided token from environment or generate a new one +const sessionToken = + process.env.MCP_PROXY_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(); +}; const createTransport = async (req: express.Request): Promise => { const query = req.query; - console.log("Query parameters:", query); + console.log("Query parameters:", JSON.stringify(query)); const transportType = query.transportType as string; @@ -64,7 +185,7 @@ const createTransport = async (req: express.Request): Promise => { const { cmd, args } = findActualExecutable(command, origArgs); - console.log(`Stdio transport: command=${cmd}, args=${args}`); + console.log(`STDIO transport: command=${cmd}, args=${args}`); const transport = new StdioClientTransport({ command: cmd, @@ -74,25 +195,15 @@ const createTransport = async (req: express.Request): Promise => { }); await transport.start(); - - console.log("Spawned stdio transport"); return transport; } else if (transportType === "sse") { const url = query.url as string; - const headers: HeadersInit = { - Accept: "text/event-stream", - }; - for (const key of SSE_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } + const headers = getHttpHeaders(req, transportType); - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } - - console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); + console.log( + `SSE transport: url=${url}, headers=${JSON.stringify(headers)}`, + ); const transport = new SSEClientTransport(new URL(url), { eventSourceInit: { @@ -103,22 +214,9 @@ const createTransport = async (req: express.Request): Promise => { }, }); await transport.start(); - - console.log("Connected to SSE transport"); return transport; } else if (transportType === "streamable-http") { - const headers: HeadersInit = { - Accept: "text/event-stream, application/json", - }; - - for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } + const headers = getHttpHeaders(req, transportType); const transport = new StreamableHTTPClientTransport( new URL(query.url as string), @@ -129,7 +227,6 @@ const createTransport = async (req: express.Request): Promise => { }, ); await transport.start(); - console.log("Connected to Streamable HTTP transport"); return transport; } else { console.error(`Invalid transport type: ${transportType}`); @@ -137,41 +234,152 @@ const createTransport = async (req: express.Request): Promise => { } }; -let backingServerTransport: Transport | undefined; - -app.get("/mcp", async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string; - console.log(`Received GET message for sessionId ${sessionId}`); - 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", async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`Received POST message for sessionId ${sessionId}`); - if (!sessionId) { +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}`); try { - console.log("New streamable-http connection"); + 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; + let serverTransport: Transport | undefined; + if (!sessionId) { try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + console.log("New StreamableHttp connection request"); + try { + serverTransport = await createTransport(req); + } catch (error) { + if (error instanceof SseError && error.code === 401) { + console.error( + "Received 401 Unauthorized from MCP server:", + error.message, + ); + res.status(401).json(error); + return; + } + + throw error; + } + + console.log("Created StreamableHttp server transport"); + + const webAppTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (sessionId) => { + webAppTransports.set(sessionId, webAppTransport); + serverTransports.set(sessionId, serverTransport!); + console.log("Client <-> Proxy sessionId: " + 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) { + console.error("Error in /mcp POST route:", error); + res.status(500).json(error); + } + } else { + console.log(`Received POST message for sessionId ${sessionId}`); + 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); + } + } + }, +); + +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}`); + let serverTransport: Transport | undefined; + if (sessionId) { + try { + serverTransport = serverTransports.get( + sessionId, + ) as StreamableHTTPClientTransport; + if (!serverTransport) { + res.status(404).end("Transport not found for sessionId " + sessionId); + } else { + await ( + serverTransport as StreamableHTTPClientTransport + ).terminateSession(); + webAppTransports.delete(sessionId); + serverTransports.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"); + let serverTransport: Transport | undefined; + try { + serverTransport = await createTransport(req); + console.log("Created server transport"); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server:", - error.message, + "Received 401 Unauthorized from MCP server. Authentication failure.", ); res.status(401).json(error); return; @@ -180,165 +388,127 @@ app.post("/mcp", async (req, res) => { throw error; } - console.log("Connected MCP client to backing server transport"); + const webAppTransport = new SSEServerTransport("/message", res); + console.log("Created client transport"); - const webAppTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: randomUUID, - onsessioninitialized: (sessionId) => { - webAppTransports.set(sessionId, webAppTransport); - console.log("Created streamable web app transport " + sessionId); - }, - }); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); + serverTransports.set(webAppTransport.sessionId, serverTransport); await webAppTransport.start(); - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: backingServerTransport, + (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { + if (chunk.toString().includes("MODULE_NOT_FOUND")) { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: "Command not found, transports removed", + }, + }); + webAppTransport.close(); + serverTransport.close(); + webAppTransports.delete(webAppTransport.sessionId); + serverTransports.delete(webAppTransport.sessionId); + console.error("Command not found, transports removed"); + } else { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + } }); - await (webAppTransport as StreamableHTTPServerTransport).handleRequest( - req, - res, - req.body, + mcpProxy({ + transportToClient: webAppTransport, + transportToServer: serverTransport, + }); + } catch (error) { + 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", ); - } catch (error) { - console.error("Error in /mcp POST route:", error); - res.status(500).json(error); - } - } else { - 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); - } - } -}); - -app.get("/stdio", async (req, res) => { - try { - console.log("New connection"); - - try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); - } catch (error) { - if (error instanceof SseError && error.code === 401) { - console.error( - "Received 401 Unauthorized from MCP server:", - error.message, - ); - res.status(401).json(error); - return; + let serverTransport: Transport | undefined; + try { + serverTransport = await createTransport(req); + } catch (error) { + if (error instanceof SseError && error.code === 401) { + 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); + } else { + throw error; + } } - throw error; - } + if (serverTransport) { + const webAppTransport = new SSEServerTransport("/message", res); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); + console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport!); + console.log("Created server transport"); - console.log("Connected MCP client to backing server transport"); + await webAppTransport.start(); - const webAppTransport = new SSEServerTransport("/message", res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - - console.log("Created web app transport"); - - await webAppTransport.start(); - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, + mcpProxy({ + transportToClient: webAppTransport, + transportToServer: serverTransport, }); - }, - ); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: backingServerTransport, - }); - - console.log("Set up MCP proxy"); - } catch (error) { - console.error("Error in /stdio route:", error); - res.status(500).json(error); - } -}); - -app.get("/sse", async (req, res) => { - try { - console.log( - "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", - ); - - try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + } } catch (error) { - if (error instanceof SseError && error.code === 401) { - console.error( - "Received 401 Unauthorized from MCP server:", - error.message, - ); - res.status(401).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; + console.log(`Received POST message for sessionId ${sessionId}`); + + const transport = webAppTransports.get( + sessionId as string, + ) as SSEServerTransport; + if (!transport) { + res.status(404).end("Session not found"); return; } - - throw error; + await transport.handlePostMessage(req, res); + } catch (error) { + console.error("Error in /message route:", error); + res.status(500).json(error); } - - console.log("Connected MCP client to backing server transport"); - - const webAppTransport = new SSEServerTransport("/message", res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - console.log("Created web app transport"); - - await webAppTransport.start(); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: backingServerTransport, - }); - - console.log("Set up MCP proxy"); - } catch (error) { - console.error("Error in /sse route:", error); - res.status(500).json(error); - } -}); - -app.post("/message", async (req, res) => { - try { - const sessionId = req.query.sessionId; - console.log(`Received message for sessionId ${sessionId}`); - - const transport = webAppTransports.get( - sessionId as string, - ) 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({ @@ -346,7 +516,7 @@ app.get("/health", (req, res) => { }); }); -app.get("/config", (req, res) => { +app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { try { res.json({ defaultEnvironment, @@ -359,11 +529,25 @@ app.get("/config", (req, res) => { } }); -const PORT = process.env.PORT || 6277; +const PORT = parseInt( + process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT, + 10, +); +const HOST = process.env.HOST || "localhost"; -const server = app.listen(PORT); +const server = app.listen(PORT, HOST); server.on("listening", () => { - console.log(`āš™ļø Proxy server listening on port ${PORT}`); + 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`)) { diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 12660504..9dee9de4 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -6,7 +6,15 @@ function onClientError(error: Error) { } function onServerError(error: Error) { - console.error("Error from MCP server:", error); + if ( + (error?.message && + error.message.includes("Error POSTing to endpoint (HTTP 404)")) || + (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) + ) { + console.error("Connection refused. Is the MCP server running?"); + } else { + console.error("Error from MCP server:", error); + } } export default function mcpProxy({ @@ -19,6 +27,8 @@ export default function mcpProxy({ 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 @@ -38,6 +48,15 @@ export default function mcpProxy({ }; 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); };