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/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..a4fd6c52 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) && + ( + github.actor == 'ihrpr' || + github.actor == 'olaservo' + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml new file mode 100644 index 00000000..8bd3bb8e --- /dev/null +++ b/.github/workflows/cli_tests.yml @@ -0,0 +1,41 @@ +name: CLI Tests + +on: + push: + paths: + - "cli/**" + pull_request: + paths: + - "cli/**" + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./cli + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: npm + + - name: Install dependencies + run: | + cd .. + npm ci --ignore-scripts + + - name: Build CLI + run: npm run build + + - name: Explicitly pre-install test dependencies + run: npx -y @modelcontextprotocol/server-everything --help || true + + - name: Run tests + run: npm test + env: + NPM_CONFIG_YES: true + CI: true diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml new file mode 100644 index 00000000..573672cc --- /dev/null +++ b/.github/workflows/e2e_tests.yml @@ -0,0 +1,78 @@ +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 + id: setup_node + with: + node-version-file: package.json + cache: npm + + # 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 ${{ steps.setup_node.outputs.node-version }} + **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 8efb6773..d01f7175 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,13 +19,16 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 18 + node-version-file: package.json cache: npm # Working around https://github.com/npm/cli/issues/4828 # - 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 @@ -50,7 +53,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version-file: package.json cache: npm registry-url: "https://registry.npmjs.org" @@ -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..6ce55639 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo cli/build test-output +# symlinked by `npm run link:sdk`: +sdk +client/playwright-report/ +client/results.json +client/test-results/ +mcp.json diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..980f29c2 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22.x.x diff --git a/.prettierignore b/.prettierignore index c8824c9a..cd910373 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ packages server/build CODE_OF_CONDUCT.md SECURITY.md +mcp.json 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..ed1e5093 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,70 @@ 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 +``` + +You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: + +```bash +MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) 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. @@ -162,6 +234,78 @@ Example server configuration file: } ``` +#### Transport Types in Config Files + +The inspector automatically detects the transport type from your config file. You can specify different transport types: + +**STDIO (default):** + +```json +{ + "mcpServers": { + "my-stdio-server": { + "type": "stdio", + "command": "npx", + "args": ["@modelcontextprotocol/server-everything"] + } + } +} +``` + +**SSE (Server-Sent Events):** + +```json +{ + "mcpServers": { + "my-sse-server": { + "type": "sse", + "url": "http://localhost:3000/sse" + } + } +} +``` + +**Streamable HTTP:** + +```json +{ + "mcpServers": { + "my-http-server": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp" + } + } +} +``` + +#### Default Server Selection + +You can launch the inspector without specifying a server name if your config has: + +1. **A single server** - automatically selected: + +```bash +# Automatically uses "my-server" if it's the only one +npx @modelcontextprotocol/inspector --config mcp.json +``` + +2. **A server named "default-server"** - automatically selected: + +```json +{ + "mcpServers": { + "default-server": { + "command": "npx", + "args": ["@modelcontextprotocol/server-everything"] + }, + "other-server": { + "command": "node", + "args": ["other.js"] + } + } +} +``` + > **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above. You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example: @@ -188,6 +332,12 @@ Development mode: ```bash npm run dev + +# To co-develop with the typescript-sdk package (assuming it's cloned in ../typescript-sdk; set MCP_SDK otherwise): +npm run dev:sdk "cd sdk && npm run examples:simple-server:w" +# then open http://localhost:3000/mcp as SHTTP in the inspector. +# To go back to the deployed SDK version: +# npm run unlink:sdk && npm i ``` > **Note for Windows users:** @@ -233,9 +383,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 --method tools/list + # 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 53f2aa6b..9458ef65 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.12.0", + "version": "0.16.4", "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.0", + "@modelcontextprotocol/sdk": "^1.17.2", "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..f714cb6a 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -12,7 +12,7 @@ const colors = { import fs from "fs"; import path from "path"; -import { execSync, spawn } from "child_process"; +import { spawn } from "child_process"; import os from "os"; import { fileURLToPath } from "url"; @@ -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 @@ -96,6 +120,85 @@ try { const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json"); fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {'); +// Create config files with different transport types for testing +const sseConfigPath = path.join(TEMP_DIR, "sse-config.json"); +fs.writeFileSync( + sseConfigPath, + JSON.stringify( + { + mcpServers: { + "test-sse": { + type: "sse", + url: "http://localhost:3000/sse", + note: "Test SSE server", + }, + }, + }, + null, + 2, + ), +); + +const httpConfigPath = path.join(TEMP_DIR, "http-config.json"); +fs.writeFileSync( + httpConfigPath, + JSON.stringify( + { + mcpServers: { + "test-http": { + type: "streamable-http", + url: "http://localhost:3000/mcp", + note: "Test HTTP server", + }, + }, + }, + null, + 2, + ), +); + +const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json"); +fs.writeFileSync( + stdioConfigPath, + JSON.stringify( + { + mcpServers: { + "test-stdio": { + type: "stdio", + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + env: { + TEST_ENV: "test-value", + }, + }, + }, + }, + null, + 2, + ), +); + +// Config without type field (backward compatibility) +const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json"); +fs.writeFileSync( + legacyConfigPath, + JSON.stringify( + { + mcpServers: { + "test-legacy": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + env: { + LEGACY_ENV: "legacy-value", + }, + }, + }, + }, + null, + 2, + ), +); + // Function to run a basic test async function runBasicTest(testName, ...args) { const outputFile = path.join( @@ -121,6 +224,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 +243,7 @@ async function runBasicTest(testName, ...args) { }); child.on("close", (code) => { + clearTimeout(timeout); outputStream.end(); if (code === 0) { @@ -201,6 +310,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 +331,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 +728,253 @@ async function runTests() { "debug", ); + console.log( + `\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`, + ); + + // Test 25: Config with stdio transport type + await runBasicTest( + "config_stdio_type", + "--config", + stdioConfigPath, + "--server", + "test-stdio", + "--cli", + "--method", + "tools/list", + ); + + // Test 26: Config with SSE transport type (CLI mode) - expects connection error + await runErrorTest( + "config_sse_type_cli", + "--config", + sseConfigPath, + "--server", + "test-sse", + "--cli", + "--method", + "tools/list", + ); + + // Test 27: Config with streamable-http transport type (CLI mode) - expects connection error + await runErrorTest( + "config_http_type_cli", + "--config", + httpConfigPath, + "--server", + "test-http", + "--cli", + "--method", + "tools/list", + ); + + // Test 28: Legacy config without type field (backward compatibility) + await runBasicTest( + "config_legacy_no_type", + "--config", + legacyConfigPath, + "--server", + "test-legacy", + "--cli", + "--method", + "tools/list", + ); + + console.log( + `\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`, + ); + + // Create config with single server for auto-selection + const singleServerConfigPath = path.join( + TEMP_DIR, + "single-server-config.json", + ); + fs.writeFileSync( + singleServerConfigPath, + JSON.stringify( + { + mcpServers: { + "only-server": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + }, + }, + null, + 2, + ), + ); + + // Create config with default-server + const defaultServerConfigPath = path.join( + TEMP_DIR, + "default-server-config.json", + ); + fs.writeFileSync( + defaultServerConfigPath, + JSON.stringify( + { + mcpServers: { + "default-server": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + "other-server": { + command: "node", + args: ["other.js"], + }, + }, + }, + null, + 2, + ), + ); + + // Create config with multiple servers (no default) + const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json"); + fs.writeFileSync( + multiServerConfigPath, + JSON.stringify( + { + mcpServers: { + server1: { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + server2: { + command: "node", + args: ["other.js"], + }, + }, + }, + null, + 2, + ), + ); + + // Test 29: Config with single server auto-selection + await runBasicTest( + "single_server_auto_select", + "--config", + singleServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + + // Test 30: Config with default-server should now require explicit selection (multiple servers) + await runErrorTest( + "default_server_requires_explicit_selection", + "--config", + defaultServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + + // Test 31: Config with multiple servers and no default (should fail) + await runErrorTest( + "multi_server_no_default", + "--config", + multiServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + + 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 32: 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 33: HTTP transport with explicit --transport http flag + await runBasicTest( + "http_transport_with_explicit_flag", + "http://127.0.0.1:3001/mcp", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 34: 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 35: 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", + ); + + // Test 36: HTTP transport without URL (should fail) + await runErrorTest( + "http_transport_without_url", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 37: SSE transport without URL (should fail) + await runErrorTest( + "sse_transport_without_url", + "--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/cli.ts b/cli/src/cli.ts index 1f87b516..13c7e492 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -14,6 +14,8 @@ type Args = { args: string[]; envArgs: Record; cli: boolean; + transport?: "stdio" | "sse" | "streamable-http"; + serverUrl?: string; }; type CliOptions = { @@ -21,13 +23,22 @@ type CliOptions = { config?: string; server?: string; cli?: boolean; + transport?: string; + serverUrl?: string; }; -type ServerConfig = { - command: string; - args?: string[]; - env?: Record; -}; +type ServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + } + | { + type: "sse" | "streamable-http"; + url: string; + note?: string; + }; function handleError(error: unknown): never { let message: string; @@ -50,28 +61,15 @@ function delay(ms: number): Promise { } async function runWebClient(args: Args): Promise { - const inspectorServerPath = resolve( - __dirname, - "../../", - "server", - "build", - "index.js", - ); - // Path to the client entry point const inspectorClientPath = resolve( __dirname, "../../", "client", "bin", - "client.js", + "start.js", ); - const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277"; - - console.log("Starting MCP inspector..."); - const abort = new AbortController(); let cancelled: boolean = false; process.on("SIGINT", () => { @@ -79,42 +77,36 @@ async function runWebClient(args: Args): Promise { abort.abort(); }); - let server: ReturnType; - let serverOk: unknown; + // Build arguments to pass to start.js + const startArgs: string[] = []; + + // Pass environment variables + for (const [key, value] of Object.entries(args.envArgs)) { + startArgs.push("-e", `${key}=${value}`); + } + + // Pass transport type if specified + if (args.transport) { + startArgs.push("--transport", args.transport); + } + + // Pass server URL if specified + if (args.serverUrl) { + startArgs.push("--server-url", args.serverUrl); + } + + // Pass command and args (using -- to separate them) + if (args.command) { + startArgs.push("--", args.command, ...args.args); + } try { - server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(args.command ? [`--env`, args.command] : []), - ...(args.args ? [`--args=${args.args.join(" ")}`] : []), - ], - { - env: { - ...process.env, - PORT: SERVER_PORT, - MCP_ENV_VARS: JSON.stringify(args.envArgs), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - - // Make sure server started before starting client - serverOk = await Promise.race([server, delay(2 * 1000)]); - } catch (error) {} - - if (serverOk) { - try { - await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } + await spawnPromise("node", [inspectorClientPath, ...startArgs], { + signal: abort.signal, + echoOutput: true, + }); + } catch (e) { + if (!cancelled || process.env.DEBUG) throw e; } } @@ -132,7 +124,21 @@ async function runCli(args: Args): Promise { }); try { - await spawnPromise("node", [cliPath, args.command, ...args.args], { + // Build CLI arguments + const cliArgs = [cliPath]; + + // Add transport flag if specified + if (args.transport && args.transport !== "stdio") { + // Convert streamable-http back to http for CLI mode + const cliTransport = + args.transport === "streamable-http" ? "http" : args.transport; + cliArgs.push("--transport", cliTransport); + } + + // Add command and remaining args + cliArgs.push(args.command, ...args.args); + + await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, echoOutput: true, @@ -219,7 +225,9 @@ function parseArgs(): Args { ) .option("--config ", "config file path") .option("--server ", "server name from config file") - .option("--cli", "enable CLI mode"); + .option("--cli", "enable CLI mode") + .option("--transport ", "transport type (stdio, sse, http)") + .option("--server-url ", "server URL for SSE/HTTP transport"); // Parse only the arguments before -- program.parse(preArgs); @@ -230,14 +238,33 @@ function parseArgs(): Args { // Add back any arguments that came after -- const finalArgs = [...remainingArgs, ...postArgs]; - // Validate that config and server are provided together - if ( - (options.config && !options.server) || - (!options.config && options.server) - ) { - throw new Error( - "Both --config and --server must be provided together. If you specify one, you must specify the other.", + // Validate config and server options + if (!options.config && options.server) { + throw new Error("--server requires --config to be specified"); + } + + // If config is provided without server, try to auto-select + if (options.config && !options.server) { + const configContent = fs.readFileSync( + path.isAbsolute(options.config) + ? options.config + : path.resolve(process.cwd(), options.config), + "utf8", ); + const parsedConfig = JSON.parse(configContent); + const servers = Object.keys(parsedConfig.mcpServers || {}); + + if (servers.length === 1) { + // Use the only server if there's just one + options.server = servers[0]; + } else if (servers.length === 0) { + throw new Error("No servers found in config file"); + } else { + // Multiple servers, require explicit selection + throw new Error( + `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`, + ); + } } // If config file is specified, load and use the options from the file. We must merge the args @@ -246,23 +273,52 @@ function parseArgs(): Args { if (options.config && options.server) { const config = loadConfigFile(options.config, options.server); - return { - command: config.command, - args: [...(config.args || []), ...finalArgs], - envArgs: { ...(config.env || {}), ...(options.e || {}) }, - cli: options.cli || false, - }; + if (config.type === "stdio") { + return { + command: config.command, + args: [...(config.args || []), ...finalArgs], + envArgs: { ...(config.env || {}), ...(options.e || {}) }, + cli: options.cli || false, + transport: "stdio", + }; + } else if (config.type === "sse" || config.type === "streamable-http") { + return { + command: config.url, + args: finalArgs, + envArgs: options.e || {}, + cli: options.cli || false, + transport: config.type, + serverUrl: config.url, + }; + } else { + // Backwards compatibility: if no type field, assume stdio + return { + command: (config as any).command || "", + args: [...((config as any).args || []), ...finalArgs], + envArgs: { ...((config as any).env || {}), ...(options.e || {}) }, + cli: options.cli || false, + transport: "stdio", + }; + } } // Otherwise use command line arguments const command = finalArgs[0] || ""; const args = finalArgs.slice(1); + // Map "http" shorthand to "streamable-http" + let transport = options.transport; + if (transport === "http") { + transport = "streamable-http"; + } + return { command, args, envArgs: options.e || {}, cli: options.cli || false, + transport: transport as "stdio" | "sse" | "streamable-http" | undefined, + serverUrl: options.serverUrl, }; } @@ -275,7 +331,7 @@ async function main(): Promise { const args = parseArgs(); if (args.cli) { - runCli(args); + await runCli(args); } else { await runWebClient(args); } diff --git a/cli/src/client/connection.ts b/cli/src/client/connection.ts index 931f803d..dcbe8e51 100644 --- a/cli/src/client/connection.ts +++ b/cli/src/client/connection.ts @@ -18,6 +18,12 @@ export async function connect( ): Promise { try { await client.connect(transport); + + if (client.getServerCapabilities()?.logging) { + // default logging level is undefined in the spec, but the user of the + // inspector most likely wants debug. + await client.setLoggingLevel("debug"); + } } catch (error) { throw new Error( `Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`, diff --git a/cli/src/index.ts b/cli/src/index.ts index bda1e8f7..2b0c4f53 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 -- @@ -245,6 +287,8 @@ async function main(): Promise { try { const args = parseArgs(); await callMethod(args); + // Explicitly exit to ensure process terminates in CI + process.exit(0); } catch (error) { handleError(error); } diff --git a/cli/src/transport.ts b/cli/src/transport.ts index 22dde73b..812e9e30 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -3,23 +3,17 @@ 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; }; -function createSSETransport(options: TransportOptions): Transport { - const baseUrl = new URL(options.url ?? ""); - const sseUrl = new URL("/sse", baseUrl); - - return new SSEClientTransport(sseUrl); -} - function createStdioTransport(options: TransportOptions): Transport { let args: string[] = []; @@ -63,8 +57,18 @@ export function createTransport(options: TransportOptions): Transport { return createStdioTransport(options); } + // If not STDIO, then it must be either SSE or HTTP. + if (!options.url) { + throw new Error("URL must be provided for SSE or HTTP transport types."); + } + const url = new URL(options.url); + if (transportType === "sse") { - return createSSETransport(options); + return new SSEClientTransport(url); + } + + if (transportType === "http") { + return new StreamableHTTPClientTransport(url); } throw new Error(`Unsupported transport type: ${transportType}`); 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..f67301de 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -2,15 +2,217 @@ 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, + transport, + serverUrl, + } = serverOptions; + const serverCommand = "npx"; + const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; + const isWindows = process.platform === "win32"; + + const spawnOptions = { + cwd: resolve(__dirname, "../..", "server"), + env: { + ...process.env, + SERVER_PORT, + CLIENT_PORT, + MCP_PROXY_AUTH_TOKEN: sessionToken, + MCP_ENV_VARS: JSON.stringify(envVars), + ...(transport ? { MCP_TRANSPORT: transport } : {}), + ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}), + }, + signal: abort.signal, + echoOutput: true, + }; + + // For Windows, we need to 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, + transport, + serverUrl, + } = serverOptions; + const inspectorServerPath = resolve( + __dirname, + "../..", + "server", + "build", + "index.js", + ); + + const server = spawnPromise( + "node", + [ + inspectorServerPath, + ...(command ? [`--command=${command}`] : []), + ...(mcpServerArgs && mcpServerArgs.length > 0 + ? [`--args=${mcpServerArgs.join(" ")}`] + : []), + ...(transport ? [`--transport=${transport}`] : []), + ...(serverUrl ? [`--server-url=${serverUrl}`] : []), + ], + { + env: { + ...process.env, + SERVER_PORT, + CLIENT_PORT, + MCP_PROXY_AUTH_TOKEN: sessionToken, + MCP_ENV_VARS: JSON.stringify(envVars), + }, + signal: abort.signal, + echoOutput: true, + }, + ); + + // Make sure server started before starting client + const serverOk = await Promise.race([server, delay(2 * 1000)]); + + return { server, serverOk }; +} + +async function startDevClient(clientOptions) { + const { + CLIENT_PORT, + SERVER_PORT, + authDisabled, + sessionToken, + abort, + cancelled, + } = clientOptions; + const clientCommand = "npx"; + const host = process.env.HOST || "localhost"; + const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; + + const 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 +220,9 @@ async function main() { const mcpServerArgs = []; let command = null; let parsingFlags = true; + let isDev = false; + let transport = null; + let serverUrl = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -27,6 +232,21 @@ async function main() { continue; } + if (parsingFlags && arg === "--dev") { + isDev = true; + continue; + } + + if (parsingFlags && arg === "--transport" && i + 1 < args.length) { + transport = args[++i]; + continue; + } + + if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { + serverUrl = args[++i]; + continue; + } + if (parsingFlags && arg === "-e" && i + 1 < args.length) { const envVar = args[++i]; const equalsIndex = envVar.indexOf("="); @@ -38,34 +258,26 @@ 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...", + ); + + // Use provided token from environment or generate a new one + const sessionToken = + process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); + const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; const abort = new AbortController(); @@ -74,40 +286,44 @@ 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, + transport, + serverUrl, + }; + + 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/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts new file mode 100644 index 00000000..a4dcdcce --- /dev/null +++ b/client/e2e/cli-arguments.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; + +// These tests verify that CLI arguments correctly set URL parameters +// The CLI should parse config files and pass transport/serverUrl as URL params +test.describe("CLI Arguments @cli", () => { + test("should pass transport parameter from command line", async ({ + page, + }) => { + // Simulate: npx . --transport sse --server-url http://localhost:3000/sse + await page.goto( + "http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse", + ); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown shows SSE + await expect(selectTrigger).toContainText("SSE"); + + // Verify URL field is visible and populated + const urlInput = page.locator("#sse-url-input"); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue("http://localhost:3000/sse"); + }); + + test("should pass transport parameter for streamable-http", async ({ + page, + }) => { + // Simulate config with streamable-http transport + await page.goto( + "http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp", + ); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown shows Streamable HTTP + await expect(selectTrigger).toContainText("Streamable HTTP"); + + // Verify URL field is visible and populated + const urlInput = page.locator("#sse-url-input"); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue("http://localhost:3000/mcp"); + }); + + test("should not pass transport parameter for stdio config", async ({ + page, + }) => { + // Simulate stdio config (no transport param needed) + await page.goto("http://localhost:6274/"); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown defaults to STDIO + await expect(selectTrigger).toContainText("STDIO"); + + // Verify command/args fields are visible + await expect(page.locator("#command-input")).toBeVisible(); + await expect(page.locator("#arguments-input")).toBeVisible(); + }); +}); 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/startup-state.spec.ts b/client/e2e/startup-state.spec.ts new file mode 100644 index 00000000..414ebd3e --- /dev/null +++ b/client/e2e/startup-state.spec.ts @@ -0,0 +1,16 @@ +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("Startup State", () => { + test("should not navigate to a tab when Inspector first opens", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Check that there is no hash fragment in the URL + const url = page.url(); + expect(url).not.toContain("#"); + }); +}); 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/index.html b/client/index.html index 00f5600b..b3736a82 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,7 @@ MCP Inspector -
+
diff --git a/client/jest.config.cjs b/client/jest.config.cjs index b4d81cfc..9b793eae 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,8 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], + randomize: true, }; diff --git a/client/package.json b/client/package.json index 2bcf2b03..441d2168 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.12.0", + "version": "0.16.4", "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.0", + "@modelcontextprotocol/sdk": "^1.17.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -34,10 +36,11 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", - "lucide-react": "^0.447.0", + "lucide-react": "^0.523.0", "pkce-challenge": "^4.1.0", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -46,19 +49,19 @@ "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.11.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", - "@types/node": "^22.7.5", + "@types/node": "^22.17.0", "@types/prismjs": "^1.26.5", - "@types/react": "^18.3.10", + "@types/react": "^18.3.23", "@types/react-dom": "^18.3.0", "@types/serve-handler": "^6.1.4", - "@vitejs/plugin-react": "^4.3.2", + "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", "co": "^4.6.0", "eslint": "^9.11.1", @@ -67,11 +70,11 @@ "globals": "^15.9.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "postcss": "^8.4.47", + "postcss": "^8.5.6", "tailwindcss": "^3.4.13", - "ts-jest": "^29.2.6", + "ts-jest": "^29.4.0", "typescript": "^5.5.3", - "typescript-eslint": "^8.7.0", - "vite": "^6.3.0" + "typescript-eslint": "^8.38.0", + "vite": "^6.3.5" } } 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.css b/client/src/App.css index 5f47310f..0d669ffa 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,7 +1,3 @@ -#root { - margin: 0 auto; -} - .logo { height: 6em; padding: 1.5em; diff --git a/client/src/App.tsx b/client/src/App.tsx index c32f4840..d6680c35 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,9 @@ 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, useCallback, @@ -28,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"; @@ -47,7 +52,7 @@ import { z } from "zod"; import "./App.css"; import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; -import HistoryAndNotifications from "./components/History"; +import HistoryAndNotifications from "./components/HistoryAndNotifications"; import PingTab from "./components/PingTab"; import PromptsTab, { Prompt } from "./components/PromptsTab"; import ResourcesTab from "./components/ResourcesTab"; @@ -58,12 +63,18 @@ import ToolsTab from "./components/ToolsTab"; import { InspectorConfig } from "./lib/configurationTypes"; import { getMCPProxyAddress, + getMCPProxyAuthToken, getInitialSseUrl, getInitialTransportType, getInitialCommand, getInitialArgs, initializeInspectorConfig, + saveInspectorConfig, } from "./utils/configUtils"; +import ElicitationTab, { + PendingElicitationRequest, + ElicitationResponse, +} from "./components/ElicitationTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -73,6 +84,9 @@ const App = () => { ResourceTemplate[] >([]); const [resourceContent, setResourceContent] = useState(""); + const [resourceContentMap, setResourceContentMap] = useState< + Record + >({}); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); @@ -109,6 +123,14 @@ const App = () => { return localStorage.getItem("lastHeaderName") || ""; }); + const [oauthClientId, setOauthClientId] = useState(() => { + return localStorage.getItem("lastOauthClientId") || ""; + }); + + const [oauthScope, setOauthScope] = useState(() => { + return localStorage.getItem("lastOauthScope") || ""; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -117,24 +139,19 @@ const App = () => { } > >([]); + const [pendingElicitationRequests, setPendingElicitationRequests] = useState< + Array< + PendingElicitationRequest & { + resolve: (response: ElicitationResponse) => void; + decline: (error: Error) => void; + } + > + >([]); 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) => { setAuthState((prev) => ({ ...prev, ...updates })); }; @@ -162,7 +179,25 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + const initialTab = hash || "resources"; + return initialTab; + }); + + const currentTabRef = useRef(activeTab); + const lastToolCallOriginTabRef = useRef(activeTab); + + useEffect(() => { + currentTabRef.current = activeTab; + }, [activeTab]); + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const { + width: sidebarWidth, + isDragging: isSidebarDragging, + handleDragStart: handleSidebarDragStart, + } = useDraggableSidebar(320); const { connectionStatus, @@ -183,6 +218,8 @@ const App = () => { env, bearerToken, headerName, + oauthClientId, + oauthScope, config, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -199,9 +236,65 @@ const App = () => { { id: nextRequestId.current++, request, resolve, reject }, ]); }, + onElicitationRequest: (request, resolve) => { + const currentTab = lastToolCallOriginTabRef.current; + + setPendingElicitationRequests((prev) => [ + ...prev, + { + id: nextRequestId.current++, + request: { + id: nextRequestId.current, + message: request.params.message, + requestedSchema: request.params.requestedSchema, + }, + originatingTab: currentTab, + resolve, + decline: (error: Error) => { + console.error("Elicitation request rejected:", error); + }, + }, + ]); + + setActiveTab("elicitations"); + window.location.hash = "elicitations"; + }, getRoots: () => rootsRef.current, + defaultLoggingLevel: logLevel, }); + useEffect(() => { + if (serverCapabilities) { + const hash = window.location.hash.slice(1); + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + const isValidTab = validTabs.includes(hash); + + if (!isValidTab) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + + setActiveTab(defaultTab); + window.location.hash = defaultTab; + } + } + }, [serverCapabilities]); + useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -227,46 +320,99 @@ const App = () => { }, [headerName]); useEffect(() => { - localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + localStorage.setItem("lastOauthClientId", oauthClientId); + }, [oauthClientId]); + + useEffect(() => { + localStorage.setItem("lastOauthScope", oauthScope); + }, [oauthScope]); + + useEffect(() => { + saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); - // Auto-connect to previously saved serverURL after OAuth callback const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); - setTransportType("sse"); setIsAuthDebuggerVisible(false); void connectMcpServer(); }, [connectMcpServer], ); - // 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) { + let currentState: AuthDebuggerState = { + ...restoredState, + authorizationCode, + oauthStep: "token_request", + isInitiatingAuth: true, + statusMessage: null, + latestError: null, + }; + + try { + const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { + currentState = { ...currentState, ...updates }; + }); + + while ( + currentState.oauthStep !== "complete" && + currentState.oauthStep !== "authorization_code" + ) { + await stateMachine.executeStep(currentState); + } + + if (currentState.oauthStep === "complete") { + updateAuthState({ + ...currentState, + statusMessage: { + type: "success", + message: "Authentication completed successfully", + }, + isInitiatingAuth: false, + }); + } + } catch (error) { + console.error("OAuth continuation error:", error); + updateAuthState({ + latestError: + error instanceof Error ? error : new Error(String(error)), + statusMessage: { + type: "error", + message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + }, + isInitiatingAuth: false, + }); + } + } else if (authorizationCode) { updateAuthState({ authorizationCode, oauthStep: "token_request", }); } - if (errorMsg) { - updateAuthState({ - latestError: new Error(errorMsg), - }); - } }, - [], + [sseUrl], ); - // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -285,8 +431,6 @@ const App = () => { } } catch (error) { console.error("Error loading OAuth tokens:", error); - } finally { - updateAuthState({ loading: false }); } }; @@ -294,7 +438,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); @@ -304,22 +455,55 @@ const App = () => { if (data.defaultArgs) { setArgs(data.defaultArgs); } + if (data.defaultTransport) { + setTransportType( + data.defaultTransport as "stdio" | "sse" | "streamable-http", + ); + } + if (data.defaultServerUrl) { + setSseUrl(data.defaultServerUrl); + } }) .catch((error) => console.error("Error fetching default environment:", error), ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [config]); useEffect(() => { rootsRef.current = roots; }, [roots]); useEffect(() => { - if (!window.location.hash) { - window.location.hash = "resources"; + if (mcpClient && !window.location.hash) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + window.location.hash = defaultTab; + } else if (!mcpClient && window.location.hash) { + // Clear hash when disconnected - completely remove the fragment + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); } - }, []); + }, [mcpClient, serverCapabilities]); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash && hash !== activeTab) { + setActiveTab(hash); + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, [activeTab]); const handleApproveSampling = (id: number, result: CreateMessageResult) => { setPendingSampleRequests((prev) => { @@ -337,6 +521,44 @@ const App = () => { }); }; + const handleResolveElicitation = ( + id: number, + response: ElicitationResponse, + ) => { + setPendingElicitationRequests((prev) => { + const request = prev.find((r) => r.id === id); + if (request) { + request.resolve(response); + + if (request.originatingTab) { + const originatingTab = request.originatingTab; + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + if (validTabs.includes(originatingTab)) { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + + setTimeout(() => { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + }, 100); + } + } + } + return prev.filter((r) => r.id !== id); + }); + }; + const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; @@ -394,7 +616,23 @@ const App = () => { setNextResourceTemplateCursor(response.nextCursor); }; + const getPrompt = async (name: string, args: Record = {}) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + const response = await sendMCPRequest( + { + method: "prompts/get" as const, + params: { name, arguments: args }, + }, + GetPromptResultSchema, + "prompts", + ); + setPromptContent(JSON.stringify(response, null, 2)); + }; + const readResource = async (uri: string) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + const response = await sendMCPRequest( { method: "resources/read" as const, @@ -403,7 +641,12 @@ const App = () => { ReadResourceResultSchema, "resources", ); - setResourceContent(JSON.stringify(response, null, 2)); + const content = JSON.stringify(response, null, 2); + setResourceContent(content); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: content, + })); }; const subscribeToResource = async (uri: string) => { @@ -451,18 +694,6 @@ const App = () => { setNextPromptCursor(response.nextCursor); }; - const getPrompt = async (name: string, args: Record = {}) => { - const response = await sendMCPRequest( - { - method: "prompts/get" as const, - params: { name, arguments: args }, - }, - GetPromptResultSchema, - "prompts", - ); - setPromptContent(JSON.stringify(response, null, 2)); - }; - const listTools = async () => { const response = await sendMCPRequest( { @@ -474,9 +705,12 @@ const App = () => { ); setTools(response.tools); setNextToolCursor(response.nextCursor); + cacheToolOutputSchemas(response.tools); }; const callTool = async (name: string, params: Record) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + try { const response = await sendMCPRequest( { @@ -492,6 +726,7 @@ const App = () => { CompatibilityCallToolResultSchema, "tools", ); + setToolResult(response); } catch (e) { const toolResult: CompatibilityCallToolResult = { @@ -526,7 +761,6 @@ const App = () => { setStdErrNotifications([]); }; - // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( { ); - // Helper function to render OAuth callback components if (window.location.pathname === "/oauth/callback") { const OAuthCallback = React.lazy( () => import("./components/OAuthCallback"), @@ -563,53 +796,73 @@ const App = () => { return (
- +
+ +
+
{mcpClient ? ( (window.location.hash = value)} + onValueChange={(value) => { + setActiveTab(value); + window.location.hash = value; + }} > - + { )} + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + Roots @@ -660,7 +922,7 @@ const App = () => { !serverCapabilities?.tools ? ( <>
-

+

The connected server does not support any MCP capabilities

@@ -760,6 +1022,7 @@ const App = () => { clearTools={() => { setTools([]); setNextToolCursor(undefined); + cacheToolOutputSchemas([]); }} callTool={async (name, params) => { clearError("tools"); @@ -775,6 +1038,11 @@ const App = () => { toolResult={toolResult} nextCursor={nextToolCursor} error={errors.tools} + resourceContent={resourceContentMap} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} /> { onApprove={handleApproveSampling} onReject={handleRejectSampling} /> + { ) : (
-

+

Connect to an MCP server to start inspecting

@@ -837,7 +1109,7 @@ const App = () => { }} >
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/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx new file mode 100644 index 00000000..dbd463f9 --- /dev/null +++ b/client/src/__tests__/App.routing.test.tsx @@ -0,0 +1,159 @@ +import { render, waitFor } from "@testing-library/react"; +import App from "../App"; +import { useConnection } from "../lib/hooks/useConnection"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +// Mock auth dependencies first +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), +})); + +jest.mock("../lib/oauth-state-machine", () => ({ + OAuthStateMachine: jest.fn(), +})); + +jest.mock("../lib/auth", () => ({ + InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockResolvedValue(null), + clear: jest.fn(), + })), + DebugInspectorOAuthClientProvider: jest.fn(), +})); + +// Mock the config utils +jest.mock("../utils/configUtils", () => ({ + ...jest.requireActual("../utils/configUtils"), + getMCPProxyAddress: jest.fn(() => "http://localhost:6277"), + getMCPProxyAuthToken: jest.fn(() => ({ + token: "", + header: "X-MCP-Proxy-Auth", + })), + getInitialTransportType: jest.fn(() => "stdio"), + getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"), + getInitialCommand: jest.fn(() => "mcp-server-everything"), + getInitialArgs: jest.fn(() => ""), + initializeInspectorConfig: jest.fn(() => ({})), + saveInspectorConfig: jest.fn(), +})); + +// Default connection state is disconnected +const disconnectedConnectionState = { + connectionStatus: "disconnected" as const, + serverCapabilities: null, + mcpClient: null, + requestHistory: [], + makeRequest: jest.fn(), + sendNotification: jest.fn(), + handleCompletion: jest.fn(), + completionsSupported: false, + connect: jest.fn(), + disconnect: jest.fn(), +}; + +// Connected state for tests that need an active connection +const connectedConnectionState = { + ...disconnectedConnectionState, + connectionStatus: "connected" as const, + serverCapabilities: {}, + mcpClient: { + request: jest.fn(), + notification: jest.fn(), + close: jest.fn(), + } as unknown as Client, +}; + +// Mock required dependencies, but unrelated to routing. +jest.mock("../lib/hooks/useDraggablePane", () => ({ + useDraggablePane: () => ({ + height: 300, + handleDragStart: jest.fn(), + }), + useDraggableSidebar: () => ({ + width: 320, + isDragging: false, + handleDragStart: jest.fn(), + }), +})); + +jest.mock("../components/Sidebar", () => ({ + __esModule: true, + default: () =>
Sidebar
, +})); + +// Mock fetch +global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) }); + +// Use an empty module mock, so that mock state can be reset between tests. +jest.mock("../lib/hooks/useConnection", () => ({ + useConnection: jest.fn(), +})); + +describe("App - URL Fragment Routing", () => { + const mockUseConnection = jest.mocked(useConnection); + + beforeEach(() => { + jest.restoreAllMocks(); + + // Inspector starts disconnected. + mockUseConnection.mockReturnValue(disconnectedConnectionState); + }); + + test("does not set hash when starting disconnected", async () => { + render(); + + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); + + test("sets default hash based on server capabilities priority", async () => { + // Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth + // + // Server capabilities determine the first three tabs; if none are present, falls back to Ping. + + const testCases = [ + { + capabilities: { resources: { listChanged: true, subscribe: true } }, + expected: "#resources", + }, + { + capabilities: { prompts: { listChanged: true, subscribe: true } }, + expected: "#prompts", + }, + { + capabilities: { tools: { listChanged: true, subscribe: true } }, + expected: "#tools", + }, + { capabilities: {}, expected: "#ping" }, + ]; + + const { rerender } = render(); + + for (const { capabilities, expected } of testCases) { + window.location.hash = ""; + mockUseConnection.mockReturnValue({ + ...connectedConnectionState, + serverCapabilities: capabilities, + }); + + rerender(); + + await waitFor(() => { + expect(window.location.hash).toBe(expected); + }); + } + }); + + test("clears hash when disconnected", async () => { + // Start with a hash set (simulating a connection) + window.location.hash = "#resources"; + + // App starts disconnected (default mock) + render(); + + // Should clear the hash when disconnected + await waitFor(() => { + expect(window.location.hash).toBe(""); + }); + }); +}); diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index a2f20e26..ec09963e 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,15 +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 { - auth, - discoverOAuthMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; 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; @@ -64,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({ @@ -124,22 +141,48 @@ const AuthDebugger = ({ updateAuthState({ isInitiatingAuth: true, statusMessage: null }); try { - const serverAuthProvider = new DebugInspectorOAuthClientProvider( - serverUrl, - ); - // First discover OAuth metadata separately so we can save it - const metadata = await discoverOAuthMetadata(serverUrl); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - serverAuthProvider.saveServerMetadata(parsedMetadata); + // Step through the OAuth flow using the state machine instead of the auth() function + let currentState: AuthDebuggerState = { + ...authState, + oauthStep: "metadata_discovery", + authorizationUrl: null, + latestError: null, + }; - await auth(serverAuthProvider, { serverUrl: serverUrl }); + const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { + // Update our temporary state during the process + currentState = { ...currentState, ...updates }; + // But don't call updateAuthState yet + }); + + // Manually step through each stage of the OAuth flow + while (currentState.oauthStep !== "complete") { + await oauthMachine.executeStep(currentState); + // In quick mode, we'll just redirect to the authorization URL + if ( + currentState.oauthStep === "authorization_code" && + currentState.authorizationUrl + ) { + // 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; + } + } + + // After the flow completes or reaches a user-input step, update the app state updateAuthState({ + ...currentState, statusMessage: { type: "info", - message: "Starting OAuth authentication process...", + message: + currentState.oauthStep === "complete" + ? "Authentication completed successfully" + : "Please complete authentication in the opened window and enter the code", }, }); } catch (error) { @@ -153,7 +196,7 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [serverUrl, updateAuthState]); + }, [serverUrl, updateAuthState, authState]); const handleClearOAuth = useCallback(() => { if (serverUrl) { @@ -162,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", @@ -208,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. +

+
{ + if (Array.isArray(type)) { + return type.every((t) => supportedTypes.includes(t)); + } + return typeof type === "string" && supportedTypes.includes(type); +}; + const isSimpleObject = (schema: JsonSchemaType): boolean => { 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 && isTypeSupported(schema.type, supportedTypes)) return true; + if (schema.type === "object") { + return Object.values(schema.properties ?? {}).every( + (prop) => prop.type && isTypeSupported(prop.type, supportedTypes), + ); + } + 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 = ({ @@ -31,6 +72,9 @@ const DynamicJsonForm = ({ const isOnlyJSON = !isSimpleObject(schema); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [jsonError, setJsonError] = useState(); + const [copiedJson, setCopiedJson] = useState(false); + const { toast } = useToast(); + // Store the raw JSON string to allow immediate feedback during typing // while deferring parsing until the user stops typing const [rawJsonValue, setRawJsonValue] = useState( @@ -113,6 +157,8 @@ const DynamicJsonForm = ({ currentValue: JsonValue, path: string[] = [], depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, ) => { if ( depth >= maxDepth && @@ -122,7 +168,8 @@ const DynamicJsonForm = ({ return ( t !== "null") ?? fieldType[0]; + } + + switch (fieldType) { + case "string": { + if ( + propSchema.oneOf && + propSchema.oneOf.every( + (option) => + typeof option.const === "string" && + typeof option.title === "string", + ) + ) { + return ( + + ); + } + + if (propSchema.enum) { + return ( + + ); + } + + let inputType = "text"; + switch (propSchema.format) { + case "email": + inputType = "email"; + break; + case "uri": + inputType = "url"; + break; + case "date": + inputType = "date"; + break; + case "date-time": + inputType = "datetime-local"; + break; + default: + inputType = "text"; + break; + } + 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} + minLength={propSchema.minLength} + maxLength={propSchema.maxLength} + pattern={propSchema.pattern} /> ); + } + case "number": return ( { 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,9 +313,12 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} + min={propSchema.minimum} + max={propSchema.maximum} /> ); + case "integer": return ( { 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); - // Only update if it's a valid integer if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); } } }} placeholder={propSchema.description} - required={propSchema.required} + required={isRequired} + min={propSchema.minimum} + max={propSchema.maximum} /> ); + case "boolean": return ( handleFieldChange(path, e.target.checked)} className="w-4 h-4" - required={propSchema.required} + required={isRequired} /> ); + case "null": + return null; + 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; } @@ -246,19 +511,64 @@ const DynamicJsonForm = ({ } }, [shouldUseJsonMode, isJsonMode]); + const handleCopyJson = useCallback(() => { + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText( + JSON.stringify(value, null, 2) ?? "[]", + ); + setCopiedJson(true); + + toast({ + title: "JSON copied", + description: + "The JSON data has been successfully copied to your clipboard.", + }); + + setTimeout(() => { + setCopiedJson(false); + }, 2000); + } catch (error) { + toast({ + title: "Error", + description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } + }; + + copyToClipboard(); + }, [toast, value]); + return (
{isJsonMode && ( - + <> + + + )} + {!isOnlyJSON && ( + + +
+
+
+ ); +}; + +export default ElicitationRequest; diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx new file mode 100644 index 00000000..cf3be2b5 --- /dev/null +++ b/client/src/components/ElicitationTab.tsx @@ -0,0 +1,56 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { TabsContent } from "@/components/ui/tabs"; +import { JsonSchemaType } from "@/utils/jsonUtils"; +import ElicitationRequest from "./ElicitationRequest"; + +export interface ElicitationRequestData { + id: number; + message: string; + requestedSchema: JsonSchemaType; +} + +export interface ElicitationResponse { + action: "accept" | "decline" | "cancel"; + content?: Record; +} + +export type PendingElicitationRequest = { + id: number; + request: ElicitationRequestData; + originatingTab?: string; +}; + +export type Props = { + pendingRequests: PendingElicitationRequest[]; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { + return ( + +
+ + + When the server requests information from the user, requests will + appear here for response. + + +
+

Recent Requests

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

No pending requests

+ )} +
+
+
+ ); +}; + +export default ElicitationTab; diff --git a/client/src/components/History.tsx b/client/src/components/HistoryAndNotifications.tsx similarity index 84% rename from client/src/components/History.tsx rename to client/src/components/HistoryAndNotifications.tsx index 0b05b55d..34c035f5 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/HistoryAndNotifications.tsx @@ -29,7 +29,9 @@ const HistoryAndNotifications = ({

History

{requestHistory.length === 0 ? ( -

No history yet

+

+ No history yet +

) : (
    {requestHistory @@ -38,7 +40,7 @@ const HistoryAndNotifications = ({ .map((request, index) => (
  • Server Notifications

    {serverNotifications.length === 0 ? ( -

    No notifications yet

    +

    + No notifications yet +

    ) : (
      {serverNotifications @@ -102,19 +106,31 @@ const HistoryAndNotifications = ({ .map((notification, index) => (
    • toggleNotificationExpansion(index)} + onClick={() => + toggleNotificationExpansion( + serverNotifications.length - 1 - index, + ) + } > {serverNotifications.length - index}.{" "} {notification.method} - {expandedNotifications[index] ? "ā–¼" : "ā–¶"} + + {expandedNotifications[ + serverNotifications.length - 1 - index + ] + ? "ā–¼" + : "ā–¶"} +
      - {expandedNotifications[index] && ( + {expandedNotifications[ + serverNotifications.length - 1 - index + ] && (
      diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 6f9a8cf7..81cc196d 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -21,8 +21,8 @@ const ListPane = ({ buttonText, isButtonDisabled, }: ListPaneProps) => ( -
      -
      +
      +

      {title}

      @@ -46,7 +46,7 @@ const ListPane = ({ {items.map((item, index) => (
      setSelectedItem(item)} > {renderItem(item)} 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 f604fc73..0e260556 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -2,6 +2,8 @@ import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; import { Button } from "./ui/button"; import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; +import { useEffect, useMemo, useState } from "react"; +import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; interface OAuthStepProps { label: string; @@ -54,24 +56,57 @@ interface OAuthFlowProgressProps { proceedToNextStep: () => Promise; } +const steps: Array = [ + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", +]; + export const OAuthFlowProgress = ({ serverUrl, authState, updateAuthState, proceedToNextStep, }: OAuthFlowProgressProps) => { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); + const provider = useMemo( + () => new DebugInspectorOAuthClientProvider(serverUrl), + [serverUrl], + ); + const [clientInfo, setClientInfo] = useState( + null, + ); - const steps: Array = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", - ]; const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); + useEffect(() => { + const fetchClientInfo = async () => { + if (authState.oauthClientInfo) { + setClientInfo(authState.oauthClientInfo); + } else { + try { + const info = await provider.clientInformation(); + if (info) { + setClientInfo(info); + } + } catch (error) { + console.error("Failed to fetch client information:", error); + } + } + }; + + if (currentStepIdx > steps.indexOf("client_registration")) { + fetchClientInfo(); + } + }, [ + provider, + authState.oauthStep, + authState.oauthClientInfo, + currentStepIdx, + ]); + // Helper to get step props const getStepProps = (stepName: OAuthStep) => ({ isComplete: @@ -93,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)}
      +                  
      +
      + )}
      )} @@ -110,13 +216,13 @@ export const OAuthFlowProgress = ({ label="Client Registration" {...getStepProps("client_registration")} > - {authState.oauthClientInfo && ( + {clientInfo && (
      Registered Client Information
      -                {JSON.stringify(authState.oauthClientInfo, null, 2)}
      +                {JSON.stringify(clientInfo, null, 2)}
                     
      )} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 5fdad823..fa590558 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -48,6 +48,7 @@ const PromptsTab = ({ ref: PromptReference | ResourceReference, argName: string, value: string, + context?: Record, ) => Promise; completionsSupported: boolean; promptContent: string; @@ -73,6 +74,7 @@ const PromptsTab = ({ }, argName, value, + promptArgs, ); } }; @@ -98,20 +100,20 @@ const PromptsTab = ({ setPromptArgs({}); }} renderItem={(prompt) => ( - <> +
      {prompt.name} - + {prompt.description} - +
      )} title="Prompts" buttonText={nextCursor ? "List More Prompts" : "List Prompts"} isButtonDisabled={!nextCursor && prompts.length > 0} /> -
      -
      +
      +

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

      @@ -126,7 +128,7 @@ const PromptsTab = ({ ) : selectedPrompt ? (
      {selectedPrompt.description && ( -

      +

      {selectedPrompt.description}

      )} diff --git a/client/src/components/ResourceLinkView.tsx b/client/src/components/ResourceLinkView.tsx new file mode 100644 index 00000000..9a42747b --- /dev/null +++ b/client/src/components/ResourceLinkView.tsx @@ -0,0 +1,112 @@ +import { useState, useCallback, useMemo, memo } from "react"; +import JsonView from "./JsonView"; + +interface ResourceLinkViewProps { + uri: string; + name?: string; + description?: string; + mimeType?: string; + resourceContent: string; + onReadResource?: (uri: string) => void; +} + +const ResourceLinkView = memo( + ({ + uri, + name, + description, + mimeType, + resourceContent, + onReadResource, + }: ResourceLinkViewProps) => { + const [{ expanded, loading }, setState] = useState({ + expanded: false, + loading: false, + }); + + const expandedContent = useMemo( + () => + expanded && resourceContent ? ( +
      +
      + Resource: +
      + +
      + ) : null, + [expanded, resourceContent], + ); + + const handleClick = useCallback(() => { + if (!onReadResource) return; + if (!expanded) { + setState((prev) => ({ ...prev, expanded: true, loading: true })); + onReadResource(uri); + setState((prev) => ({ ...prev, loading: false })); + } else { + setState((prev) => ({ ...prev, expanded: false })); + } + }, [expanded, onReadResource, uri]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === " ") && onReadResource) { + e.preventDefault(); + handleClick(); + } + }, + [handleClick, onReadResource], + ); + + return ( +
      +
      +
      +
      + + {uri} + +
      + {mimeType && ( + + {mimeType} + + )} + {onReadResource && ( +
      + {name && ( +
      + {name} +
      + )} + {description && ( +

      + {description} +

      + )} +
      +
      + {expandedContent} +
      + ); + }, +); + +export default ResourceLinkView; diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index c0f8203f..874e0ff7 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, @@ -51,6 +52,7 @@ const ResourcesTab = ({ ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, ) => Promise; completionsSupported: boolean; resourceContent: string; @@ -79,10 +81,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) => { @@ -96,6 +95,7 @@ const ResourcesTab = ({ }, key, value, + templateValues, ); } }; @@ -173,8 +173,8 @@ const ResourcesTab = ({ isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} /> -
      -
      +
      +

      ) : selectedTemplate ? (
      -

      +

      {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] || []} + /> +
      + ); + })} - {showBearerToken && ( -
      - - - setHeaderName && setHeaderName(e.target.value) - } - data-testid="header-input" - className="font-mono" - value={headerName} - /> - - setBearerToken(e.target.value)} - data-testid="bearer-token-input" - className="font-mono" - type="password" - /> -
      + {sseUrl ? ( + + + setSseUrl(e.target.value)} + className="font-mono" + /> + + {sseUrl} + + ) : ( + setSseUrl(e.target.value)} + className="font-mono" + /> )}
      @@ -506,6 +484,94 @@ const Sidebar = ({

      +
      + + {showAuthConfig && ( + <> + {/* Bearer Token Section */} +
      +

      + API Token Authentication +

      +
      + + + setHeaderName && setHeaderName(e.target.value) + } + data-testid="header-input" + className="font-mono" + value={headerName} + /> + + setBearerToken(e.target.value)} + data-testid="bearer-token-input" + className="font-mono" + type="password" + /> +
      +
      + {transportType !== "stdio" && ( + // OAuth Configuration +
      +

      + OAuth 2.0 Flow +

      +
      + + setOauthClientId(e.target.value)} + value={oauthClientId} + data-testid="oauth-client-id-input" + className="font-mono" + /> + + + + setOauthScope(e.target.value)} + value={oauthScope} + data-testid="oauth-scope-input" + className="font-mono" + /> +
      +
      + )} + + )} +
      {/* Configuration */}
      +
      +
      + +
      +
      + )} + {selectedTool && + hasMeta(selectedTool) && + selectedTool._meta && ( +
      +
      +

      Meta:

      + +
      +
      + +
      +
      + )} - {toolResult && renderToolResult()} +
      ) : ( diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index aa206ecc..b3da23fd 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -25,6 +25,7 @@ const mockOAuthMetadata = { token_endpoint: "https://oauth.example.com/token", response_types_supported: ["code"], grant_types_supported: ["authorization_code"], + scopes_supported: ["read", "write"], }; const mockOAuthClientInfo = { @@ -36,26 +37,82 @@ const mockOAuthClientInfo = { // Mock MCP SDK functions - must be before imports jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), - discoverOAuthMetadata: jest.fn(), + discoverAuthorizationServerMetadata: jest.fn(), registerClient: jest.fn(), startAuthorization: jest.fn(), exchangeAuthorization: jest.fn(), + discoverOAuthProtectedResourceMetadata: jest.fn(), + selectResourceURL: jest.fn(), })); // Import the functions to get their types import { - discoverOAuthMetadata, + discoverAuthorizationServerMetadata, registerClient, startAuthorization, exchangeAuthorization, auth, + discoverOAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; + +// Mock local auth module +jest.mock("@/lib/auth", () => ({ + DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ + tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + clear: jest.fn().mockImplementation(() => { + // Mock the real clear() behavior which removes items from sessionStorage + sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens"); + sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info"); + sessionStorage.removeItem( + "[https://example.com/mcp] mcp_server_metadata", + ); + }), + redirectUrl: "http://localhost:3000/oauth/callback/debug", + clientMetadata: { + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + }, + clientInformation: jest.fn().mockImplementation(async () => { + const serverUrl = "https://example.com/mcp"; + const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`; + const preregisteredData = sessionStorage.getItem(preregisteredKey); + if (preregisteredData) { + return JSON.parse(preregisteredData); + } + const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; + const dynamicData = sessionStorage.getItem(dynamicKey); + if (dynamicData) { + return JSON.parse(dynamicData); + } + return undefined; + }), + saveClientInformation: jest.fn().mockImplementation((clientInfo) => { + const serverUrl = "https://example.com/mcp"; + const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; + sessionStorage.setItem(key, JSON.stringify(clientInfo)); + }), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + saveServerMetadata: jest.fn(), + getServerMetadata: jest.fn(), + })), + discoverScopes: jest.fn().mockResolvedValue("read write" as never), +})); + +import { discoverScopes } from "@/lib/auth"; // Type the mocked functions properly -const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< - typeof discoverOAuthMetadata ->; +const mockDiscoverAuthorizationServerMetadata = + discoverAuthorizationServerMetadata as jest.MockedFunction< + typeof discoverAuthorizationServerMetadata + >; const mockRegisterClient = registerClient as jest.MockedFunction< typeof registerClient >; @@ -66,6 +123,13 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< typeof exchangeAuthorization >; const mockAuth = auth as jest.MockedFunction; +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; +const mockDiscoverScopes = discoverScopes as jest.MockedFunction< + typeof discoverScopes +>; const sessionStorageMock = { getItem: jest.fn(), @@ -77,29 +141,11 @@ Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); -Object.defineProperty(window, "location", { - value: { - origin: "http://localhost:3000", - }, -}); - describe("AuthDebugger", () => { - const defaultAuthState = { - isInitiatingAuth: false, - oauthTokens: null, - loading: false, - oauthStep: "metadata_discovery" as const, - oauthMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }; + const defaultAuthState = EMPTY_DEBUGGER_STATE; const defaultProps = { - serverUrl: "https://example.com", + serverUrl: "https://example.com/mcp", onBack: jest.fn(), authState: defaultAuthState, updateAuthState: jest.fn(), @@ -109,8 +155,22 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + // Suppress console errors in tests to avoid JSDOM navigation noise + jest.spyOn(console, "error").mockImplementation(() => {}); + + // Set default mock behaviors with complete OAuth metadata + mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ + issuer: "https://oauth.example.com", + authorization_endpoint: "https://oauth.example.com/authorize", + token_endpoint: "https://oauth.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + scopes_supported: ["read", "write"], + }); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( + new Error("No protected resource metadata found"), + ); mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { const authUrl = new URL("https://oauth.example.com/authorize"); @@ -126,6 +186,10 @@ describe("AuthDebugger", () => { mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const renderAuthDebugger = (props: Partial = {}) => { const mergedProps = { ...defaultProps, @@ -203,26 +267,20 @@ describe("AuthDebugger", () => { }); // Should first discover and save OAuth metadata - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( + new URL("https://example.com/"), ); - // Then should call auth with the server provider - expect(mockAuth).toHaveBeenCalled(); - // Check that updateAuthState was called with the right info message expect(updateAuthState).toHaveBeenCalledWith( expect.objectContaining({ - statusMessage: { - type: "info", - message: "Starting OAuth authentication process...", - }, + oauthStep: "authorization_code", }), ); }); it("should show error when quick OAuth flow fails to discover metadata", async () => { - mockDiscoverOAuthMetadata.mockRejectedValue( + mockDiscoverAuthorizationServerMetadata.mockRejectedValue( new Error("Metadata discovery failed"), ); @@ -322,6 +380,12 @@ describe("AuthDebugger", () => { }); expect(updateAuthState).toHaveBeenCalledWith({ + authServerUrl: null, + authorizationUrl: null, + isInitiatingAuth: false, + resourceMetadata: null, + resourceMetadataError: null, + resource: null, oauthTokens: null, oauthStep: "metadata_discovery", latestError: null, @@ -362,8 +426,8 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Continue")); }); - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( + new URL("https://example.com/"), ); }); @@ -424,7 +488,24 @@ describe("AuthDebugger", () => { }); }); - it("should not include scope in authorization URL when scopes_supported is not present", async () => { + it("should include scope in authorization URL when scopes_supported is not present", async () => { + const updateAuthState = + await setupAuthorizationUrlTest(mockOAuthMetadata); + + // Wait for the updateAuthState to be called + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl: expect.stringContaining("scope="), + }), + ); + }); + }); + + it("should omit scope from authorization URL when discoverScopes returns undefined", async () => { + // Mock discoverScopes to return undefined (no scopes available) + mockDiscoverScopes.mockResolvedValueOnce(undefined); + const updateAuthState = await setupAuthorizationUrlTest(mockOAuthMetadata); @@ -438,4 +519,275 @@ describe("AuthDebugger", () => { }); }); }); + + describe("Client Registration behavior", () => { + it("uses preregistered (static) client information without calling DCR", async () => { + const preregClientInfo = { + client_id: "static_client_id", + client_secret: "static_client_secret", + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], + }; + + // Return preregistered client info for the server-specific key + sessionStorageMock.getItem.mockImplementation((key) => { + if ( + key === + `[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}` + ) { + return JSON.stringify(preregClientInfo); + } + return null; + }); + + const updateAuthState = jest.fn(); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "client_registration", + oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + }, + }); + }); + + // Proceed from client_registration → authorization_redirect + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + // Should NOT attempt dynamic client registration + expect(mockRegisterClient).not.toHaveBeenCalled(); + + // Should advance with the preregistered client info + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientInfo: expect.objectContaining({ + client_id: "static_client_id", + }), + oauthStep: "authorization_redirect", + }), + ); + }); + + it("falls back to DCR when no static client information is available", async () => { + // No preregistered or dynamic client info present in session storage + sessionStorageMock.getItem.mockImplementation(() => null); + + // DCR returns a new client + mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo); + + const updateAuthState = jest.fn(); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "client_registration", + oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Continue")); + }); + + expect(mockRegisterClient).toHaveBeenCalledTimes(1); + + // Should save and advance with the DCR client info + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientInfo: expect.objectContaining({ + client_id: "test_client_id", + }), + oauthStep: "authorization_redirect", + }), + ); + + // Verify the dynamically registered client info was persisted + expect(sessionStorage.setItem).toHaveBeenCalledWith( + `[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`, + expect.any(String), + ); + }); + }); + + describe("OAuth State Persistence", () => { + it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { + const updateAuthState = jest.fn(); + + // Setup mocks for OAuth flow + mockStartAuthorization.mockResolvedValue({ + authorizationUrl: new URL( + "https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug", + ), + codeVerifier: "test_verifier", + }); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState }, + }); + }); + + // Click Quick OAuth Flow + await act(async () => { + fireEvent.click(screen.getByText("Quick OAuth Flow")); + }); + + // Wait for the flow to reach the authorization step + await waitFor(() => { + expect(sessionStorage.setItem).toHaveBeenCalledWith( + SESSION_KEYS.AUTH_DEBUGGER_STATE, + expect.stringContaining('"oauthStep":"authorization_code"'), + ); + }); + + // Verify the stored state includes all the accumulated data + const storedStateCall = ( + sessionStorage.setItem as jest.Mock + ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); + + expect(storedStateCall).toBeDefined(); + const storedState = JSON.parse(storedStateCall![1] as string); + + expect(storedState).toMatchObject({ + oauthStep: "authorization_code", + authorizationUrl: expect.stringMatching( + /^https:\/\/oauth\.example\.com\/authorize/, + ), + oauthMetadata: expect.objectContaining({ + token_endpoint: "https://oauth.example.com/token", + }), + oauthClientInfo: expect.objectContaining({ + client_id: "test_client_id", + }), + }); + }); + }); + + describe("OAuth Protected Resource Metadata", () => { + it("should successfully fetch and display protected resource metadata", async () => { + const updateAuthState = jest.fn(); + const mockResourceMetadata = { + resource: "https://example.com/mcp", + authorization_servers: ["https://custom-auth.example.com"], + bearer_methods_supported: ["header", "body"], + resource_documentation: "https://example.com/mcp/docs", + resource_policy_uri: "https://example.com/mcp/policy", + }; + + // Mock successful metadata discovery + mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( + mockResourceMetadata, + ); + mockDiscoverAuthorizationServerMetadata.mockResolvedValue( + mockOAuthMetadata, + ); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState }, + }); + }); + + // Click Guided OAuth Flow to start the process + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // Verify that the flow started with metadata discovery + expect(updateAuthState).toHaveBeenCalledWith({ + oauthStep: "metadata_discovery", + authorizationUrl: null, + statusMessage: null, + latestError: null, + }); + + // Click Continue to trigger metadata discovery + const continueButton = await screen.findByText("Continue"); + await act(async () => { + fireEvent.click(continueButton); + }); + + // Wait for the metadata to be fetched + await waitFor(() => { + expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( + "https://example.com/mcp", + ); + }); + + // Verify the state was updated with the resource metadata + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadata: mockResourceMetadata, + authServerUrl: new URL("https://custom-auth.example.com"), + oauthStep: "client_registration", + }), + ); + }); + }); + + it("should handle protected resource metadata fetch failure gracefully", async () => { + const updateAuthState = jest.fn(); + const mockError = new Error("Failed to fetch resource metadata"); + + // Mock failed metadata discovery + mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError); + // But OAuth metadata should still work with the original URL + mockDiscoverAuthorizationServerMetadata.mockResolvedValue( + mockOAuthMetadata, + ); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { ...defaultAuthState }, + }); + }); + + // Click Guided OAuth Flow + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // Click Continue to trigger metadata discovery + const continueButton = await screen.findByText("Continue"); + await act(async () => { + fireEvent.click(continueButton); + }); + + // Wait for the metadata fetch to fail + await waitFor(() => { + expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( + "https://example.com/mcp", + ); + }); + + // Verify the flow continues despite the error + await waitFor(() => { + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + resourceMetadataError: mockError, + // Should use the original server URL as fallback + authServerUrl: new URL("https://example.com/"), + oauthStep: "client_registration", + }), + ); + }); + + // Verify that regular OAuth metadata discovery was still called + expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( + new URL("https://example.com/"), + ); + }); + }); }); diff --git a/client/src/components/__tests__/DynamicJsonForm.array.test.tsx b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx new file mode 100644 index 00000000..00a324bf --- /dev/null +++ b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx @@ -0,0 +1,256 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, jest } from "@jest/globals"; +import DynamicJsonForm from "../DynamicJsonForm"; +import type { JsonSchemaType } from "@/utils/jsonUtils"; + +describe("DynamicJsonForm Array Fields", () => { + const renderSimpleArrayForm = (props = {}) => { + const defaultProps = { + schema: { + type: "array" as const, + description: "Test array field", + items: { + type: "string" as const, + description: "Array item", + }, + } satisfies JsonSchemaType, + value: [], + onChange: jest.fn(), + }; + return render(); + }; + + const renderComplexArrayForm = (props = {}) => { + const defaultProps = { + schema: { + type: "array" as const, + description: "Test complex array field", + items: { + type: "object" as const, + properties: { + nested: { type: "object" as const }, + }, + }, + } satisfies JsonSchemaType, + value: [], + onChange: jest.fn(), + }; + return render(); + }; + + describe("Simple Array Rendering", () => { + it("should render form fields for simple array items", () => { + renderSimpleArrayForm({ value: ["item1", "item2"] }); + + // Should show array description + expect(screen.getByText("Test array field")).toBeDefined(); + expect(screen.getByText("Items: Array item")).toBeDefined(); + + // Should show input fields for each item + const inputs = screen.getAllByRole("textbox"); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toHaveProperty("value", "item1"); + expect(inputs[1]).toHaveProperty("value", "item2"); + + // Should show remove buttons + const removeButtons = screen.getAllByText("Remove"); + expect(removeButtons).toHaveLength(2); + + // Should show add button + expect(screen.getByText("Add Item")).toBeDefined(); + }); + + it("should add new items when Add Item button is clicked", () => { + const onChange = jest.fn(); + renderSimpleArrayForm({ value: ["item1"], onChange }); + + const addButton = screen.getByText("Add Item"); + fireEvent.click(addButton); + + expect(onChange).toHaveBeenCalledWith(["item1", ""]); + }); + + it("should remove items when Remove button is clicked", () => { + const onChange = jest.fn(); + renderSimpleArrayForm({ value: ["item1", "item2"], onChange }); + + const removeButtons = screen.getAllByText("Remove"); + fireEvent.click(removeButtons[0]); + + expect(onChange).toHaveBeenCalledWith(["item2"]); + }); + + it("should update item values when input changes", () => { + const onChange = jest.fn(); + renderSimpleArrayForm({ value: ["item1"], onChange }); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "updated item" } }); + + expect(onChange).toHaveBeenCalledWith(["updated item"]); + }); + + it("should handle empty arrays", () => { + renderSimpleArrayForm({ value: [] }); + + // Should show description and add button but no items + expect(screen.getByText("Test array field")).toBeDefined(); + expect(screen.getByText("Add Item")).toBeDefined(); + expect(screen.queryByText("Remove")).toBeNull(); + }); + }); + + describe("Complex Array Fallback", () => { + it("should render JSON editor for complex arrays", () => { + renderComplexArrayForm(); + + // Should render as JSON editor (textarea) + const textarea = screen.getByRole("textbox"); + expect(textarea).toHaveProperty("type", "textarea"); + + // Should not show form-specific array controls + expect(screen.queryByText("Add Item")).toBeNull(); + expect(screen.queryByText("Remove")).toBeNull(); + }); + }); + + describe("Array Type Detection", () => { + it("should detect string arrays as simple", () => { + const schema = { + type: "array" as const, + items: { type: "string" as const }, + }; + renderSimpleArrayForm({ schema, value: ["test"] }); + + // Should render form fields, not JSON editor + expect(screen.getByRole("textbox")).not.toHaveProperty( + "type", + "textarea", + ); + }); + + it("should detect number arrays as simple", () => { + const schema = { + type: "array" as const, + items: { type: "number" as const }, + }; + renderSimpleArrayForm({ schema, value: [1, 2] }); + + // Should render form fields (number inputs) + const inputs = screen.getAllByRole("spinbutton"); + expect(inputs).toHaveLength(2); + }); + + it("should detect boolean arrays as simple", () => { + const schema = { + type: "array" as const, + items: { type: "boolean" as const }, + }; + renderSimpleArrayForm({ schema, value: [true, false] }); + + // Should render form fields (checkboxes) + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(2); + }); + + it("should detect simple object arrays as simple", () => { + const schema = { + type: "array" as const, + items: { + type: "object" as const, + properties: { + name: { type: "string" as const }, + age: { type: "number" as const }, + }, + }, + }; + renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] }); + + // Should render form fields for simple objects + expect(screen.getByText("Add Item")).toBeDefined(); + expect(screen.getByText("Remove")).toBeDefined(); + }); + }); + + describe("Array with Different Item Types", () => { + it("should handle integer array items", () => { + const schema = { + type: "array" as const, + items: { type: "integer" as const }, + }; + const onChange = jest.fn(); + renderSimpleArrayForm({ schema, value: [1, 2], onChange }); + + const inputs = screen.getAllByRole("spinbutton"); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toHaveProperty("value", "1"); + expect(inputs[1]).toHaveProperty("value", "2"); + + // Test adding new integer item + const addButton = screen.getByText("Add Item"); + fireEvent.click(addButton); + expect(onChange).toHaveBeenCalledWith([1, 2, 0]); + }); + + it("should handle boolean array items", () => { + const schema = { + type: "array" as const, + items: { type: "boolean" as const }, + }; + const onChange = jest.fn(); + renderSimpleArrayForm({ schema, value: [true, false], onChange }); + + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0]).toHaveProperty("checked", true); + expect(checkboxes[1]).toHaveProperty("checked", false); + + // Test adding new boolean item + const addButton = screen.getByText("Add Item"); + fireEvent.click(addButton); + expect(onChange).toHaveBeenCalledWith([true, false, false]); + }); + }); + + describe("Array Item Descriptions", () => { + it("should show item description when available", () => { + const schema = { + type: "array" as const, + description: "List of names", + items: { + type: "string" as const, + description: "Person name", + }, + }; + renderSimpleArrayForm({ schema }); + + expect(screen.getByText("List of names")).toBeDefined(); + expect(screen.getByText("Items: Person name")).toBeDefined(); + }); + + it("should use item description in add button title", () => { + const schema = { + type: "array" as const, + items: { + type: "string" as const, + description: "Email address", + }, + }; + renderSimpleArrayForm({ schema }); + + const addButton = screen.getByText("Add Item"); + expect(addButton).toHaveProperty("title", "Add new Email address"); + }); + + it("should use default title when no item description", () => { + const schema = { + type: "array" as const, + items: { type: "string" as const }, + }; + renderSimpleArrayForm({ schema }); + + const addButton = screen.getByText("Add Item"); + expect(addButton).toHaveProperty("title", "Add new item"); + }); + }); +}); diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index afd435d7..318fba2d 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; import { describe, it, expect, jest } from "@jest/globals"; import DynamicJsonForm from "../DynamicJsonForm"; import type { JsonSchemaType } from "@/utils/jsonUtils"; @@ -34,6 +35,200 @@ describe("DynamicJsonForm String Fields", () => { const input = screen.getByRole("textbox"); expect(input).toHaveProperty("type", "text"); }); + + it("should handle a union type of string and null", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Test string or null field", + }; + render( + , + ); + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "text"); + }); + }); + + describe("Format Support", () => { + it("should render email input for email format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "email", + description: "Email address", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "email"); + }); + + it("should render url input for uri format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "uri", + description: "Website URL", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "url"); + }); + + it("should render date input for date format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "date", + description: "Birth date", + }; + render(); + + const input = screen.getByDisplayValue(""); + expect(input).toHaveProperty("type", "date"); + }); + + it("should render datetime-local input for date-time format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "date-time", + description: "Event datetime", + }; + render(); + + const input = screen.getByDisplayValue(""); + expect(input).toHaveProperty("type", "datetime-local"); + }); + }); + + describe("Enum Support", () => { + it("should render select dropdown for enum fields", () => { + const schema: JsonSchemaType = { + type: "string", + enum: ["option1", "option2", "option3"], + description: "Select an option", + }; + render(); + + const select = screen.getByRole("combobox"); + expect(select.tagName).toBe("SELECT"); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(4); + }); + + it("should use oneOf with const and title for labeled options", () => { + const schema: JsonSchemaType = { + type: "string", + oneOf: [ + { const: "val1", title: "Label 1" }, + { const: "val2", title: "Label 2" }, + ], + description: "Select with labels", + }; + render(); + + const options = screen.getAllByRole("option"); + expect(options[1]).toHaveProperty("textContent", "Label 1"); + expect(options[2]).toHaveProperty("textContent", "Label 2"); + }); + + it("should call onChange with selected oneOf value", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "string", + oneOf: [ + { const: "option1", title: "Option 1" }, + { const: "option2", title: "Option 2" }, + ], + description: "Select an option", + }; + render(); + + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "option1" } }); + + expect(onChange).toHaveBeenCalledWith("option1"); + }); + + it("should call onChange with selected enum value", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "string", + enum: ["option1", "option2"], + description: "Select an option", + }; + render(); + + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "option1" } }); + + expect(onChange).toHaveBeenCalledWith("option1"); + }); + + it("should render JSON Schema spec compliant oneOf with const for labeled enums", () => { + // Example from JSON Schema spec: labeled enums using oneOf with const + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "string", + title: "Traffic Light", + description: "Select a traffic light color", + oneOf: [ + { const: "red", title: "Stop" }, + { const: "amber", title: "Caution" }, + { const: "green", title: "Go" }, + ], + }; + render(); + + // Should render as a select dropdown + const select = screen.getByRole("combobox"); + expect(select.tagName).toBe("SELECT"); + + // Should have options with proper labels + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(4); // 3 options + 1 default "Select an option..." + + expect(options[0]).toHaveProperty("textContent", "Select an option..."); + expect(options[1]).toHaveProperty("textContent", "Stop"); + expect(options[2]).toHaveProperty("textContent", "Caution"); + expect(options[3]).toHaveProperty("textContent", "Go"); + + // Should have proper values + expect(options[1]).toHaveProperty("value", "red"); + expect(options[2]).toHaveProperty("value", "amber"); + expect(options[3]).toHaveProperty("value", "green"); + + // Test onChange behavior + fireEvent.change(select, { target: { value: "amber" } }); + expect(onChange).toHaveBeenCalledWith("amber"); + }); + }); + + describe("Validation Attributes", () => { + it("should apply minLength and maxLength", () => { + const schema: JsonSchemaType = { + type: "string", + minLength: 3, + maxLength: 10, + description: "Username", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("minLength", 3); + expect(input).toHaveProperty("maxLength", 10); + }); + + it("should apply pattern validation", () => { + const schema: JsonSchemaType = { + type: "string", + pattern: "^[A-Za-z]+$", + description: "Letters only", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("pattern", "^[A-Za-z]+$"); + }); }); }); @@ -81,6 +276,38 @@ describe("DynamicJsonForm Integer Fields", () => { }); }); + describe("Validation", () => { + it("should apply min and max constraints", () => { + const schema: JsonSchemaType = { + type: "integer", + minimum: 0, + maximum: 100, + description: "Age", + }; + render( + , + ); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveProperty("min", "0"); + expect(input).toHaveProperty("max", "100"); + }); + + it("should only accept integer values", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "integer", + description: "Count", + }; + render(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "3.14" } }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + describe("Edge Cases", () => { it("should handle non-numeric input by not calling onChange", () => { const onChange = jest.fn(); @@ -94,6 +321,233 @@ describe("DynamicJsonForm Integer Fields", () => { }); }); +describe("DynamicJsonForm Number Fields", () => { + describe("Validation", () => { + it("should apply min and max constraints", () => { + const schema: JsonSchemaType = { + type: "number", + minimum: 0.5, + maximum: 99.9, + description: "Score", + }; + render( + , + ); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveProperty("min", "0.5"); + expect(input).toHaveProperty("max", "99.9"); + }); + + it("should accept decimal values", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "number", + description: "Temperature", + }; + render(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "98.6" } }); + + expect(onChange).toHaveBeenCalledWith(98.6); + }); + }); +}); + +describe("DynamicJsonForm Boolean Fields", () => { + describe("Basic Operations", () => { + it("should render checkbox for boolean type", () => { + const schema: JsonSchemaType = { + type: "boolean", + description: "Enable notifications", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toHaveProperty("type", "checkbox"); + }); + + it("should call onChange with boolean value", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "boolean", + description: "Accept terms", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should render boolean field with description", () => { + const schema: JsonSchemaType = { + type: "boolean", + description: "Enable dark mode", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toHaveProperty("checked", false); + }); + }); +}); + +describe("DynamicJsonForm Object Fields", () => { + describe("Property Rendering", () => { + it("should render input fields for object properties", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Your full name", + }, + age: { + type: "integer", + title: "Age", + description: "Your age in years", + minimum: 18, + }, + email: { + type: "string", + format: "email", + title: "Email", + description: "Your email address", + }, + }, + }; + render( + , + ); + + const textInputs = screen.getAllByRole("textbox"); + const numberInput = screen.getByRole("spinbutton"); + + expect(textInputs).toHaveLength(2); + expect(textInputs[0]).toHaveProperty("type", "text"); + expect(textInputs[1]).toHaveProperty("type", "email"); + expect(numberInput).toHaveProperty("type", "number"); + expect(numberInput).toHaveProperty("min", "18"); + }); + + it("should handle object field changes correctly", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + username: { + type: "string", + description: "Your username", + }, + }, + }; + render( + , + ); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "testuser" } }); + + expect(onChange).toHaveBeenCalledWith({ username: "testuser" }); + }); + + it("should handle nested object values correctly", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + }, + }; + render( + , + ); + + const input = screen.getByDisplayValue("John"); + fireEvent.change(input, { target: { value: "Jane" } }); + + expect(onChange).toHaveBeenCalledWith({ name: "Jane" }); + }); + }); + + describe("Required Fields", () => { + it("should mark required fields with required attribute", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + email: { + type: "string", + title: "Email", + }, + }, + required: ["name"], + }; + render( + , + ); + + const inputs = screen.getAllByRole("textbox"); + const nameInput = inputs[0]; + const emailInput = inputs[1]; + + expect(nameInput).toHaveProperty("required", true); + expect(emailInput).toHaveProperty("required", false); + }); + + it("should mark required fields with required attribute", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + optional: { + type: "string", + title: "Optional", + }, + }, + required: ["name"], + }; + render( + , + ); + + const nameLabel = screen.getByText("name"); + const optionalLabel = screen.getByText("optional"); + + const nameInput = nameLabel.closest("div")?.querySelector("input"); + const optionalInput = optionalLabel + .closest("div") + ?.querySelector("input"); + + expect(nameInput).toHaveProperty("required", true); + expect(optionalInput).toHaveProperty("required", false); + }); + }); +}); + describe("DynamicJsonForm Complex Fields", () => { const renderForm = (props = {}) => { const defaultProps = { @@ -117,8 +571,11 @@ describe("DynamicJsonForm Complex Fields", () => { const input = screen.getByRole("textbox"); expect(input).toHaveProperty("type", "textarea"); const buttons = screen.getAllByRole("button"); - expect(buttons).toHaveLength(1); - expect(buttons[0]).toHaveProperty("textContent", "Format JSON"); + expect(buttons).toHaveLength(2); // Copy JSON + Format JSON + const copyButton = screen.getByRole("button", { name: /copy json/i }); + const formatButton = screen.getByRole("button", { name: /format json/i }); + expect(copyButton).toBeTruthy(); + expect(formatButton).toBeTruthy(); }); it("should pass changed values to onChange", () => { @@ -137,3 +594,72 @@ describe("DynamicJsonForm Complex Fields", () => { }); }); }); + +describe("DynamicJsonForm Copy JSON Functionality", () => { + const mockWriteText = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, + }); + }); + + const renderFormInJsonMode = (props = {}) => { + const defaultProps = { + schema: { + type: "object", + properties: { + nested: { oneOf: [{ type: "string" }, { type: "integer" }] }, + }, + } as unknown as JsonSchemaType, + value: { nested: "test value" }, + onChange: jest.fn(), + }; + return render(); + }; + + describe("Copy JSON Button", () => { + it("should render Copy JSON button when in JSON mode", () => { + renderFormInJsonMode(); + + const copyButton = screen.getByRole("button", { name: "Copy JSON" }); + expect(copyButton).toBeTruthy(); + }); + + it("should not render Copy JSON button when not in JSON mode", () => { + const simpleSchema = { + type: "string" as const, + description: "Test string field", + }; + + render( + , + ); + + const copyButton = screen.queryByRole("button", { name: "Copy JSON" }); + expect(copyButton).toBeNull(); + }); + + it("should copy JSON to clipboard when clicked", async () => { + const testValue = { nested: "test value", number: 42 }; + + renderFormInJsonMode({ value: testValue }); + + const copyButton = screen.getByRole("button", { name: "Copy JSON" }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith( + JSON.stringify(testValue, null, 2), + ); + }); + }); + }); +}); diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationRequest.test.tsx new file mode 100644 index 00000000..f2af2593 --- /dev/null +++ b/client/src/components/__tests__/ElicitationRequest.test.tsx @@ -0,0 +1,202 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach, afterEach } from "@jest/globals"; +import ElicitationRequest from "../ElicitationRequest"; +import { PendingElicitationRequest } from "../ElicitationTab"; + +jest.mock("../DynamicJsonForm", () => { + return function MockDynamicJsonForm({ + value, + onChange, + }: { + value: unknown; + onChange: (value: unknown) => void; + }) { + return ( +
      + { + try { + const parsed = JSON.parse(e.target.value); + onChange(parsed); + } catch { + onChange(e.target.value); + } + }} + /> +
      + ); + }; +}); + +describe("ElicitationRequest", () => { + const mockOnResolve = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createMockRequest = ( + overrides: Partial = {}, + ): PendingElicitationRequest => ({ + id: 1, + request: { + id: 1, + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { type: "string", description: "Your name" }, + email: { type: "string", format: "email", description: "Your email" }, + }, + required: ["name"], + }, + }, + ...overrides, + }); + + const renderElicitationRequest = ( + request: PendingElicitationRequest = createMockRequest(), + ) => { + return render( + , + ); + }; + + describe("Rendering", () => { + it("should render the component", () => { + renderElicitationRequest(); + expect(screen.getByTestId("elicitation-request")).toBeInTheDocument(); + }); + + it("should display request message", () => { + const message = "Please provide your GitHub username"; + renderElicitationRequest( + createMockRequest({ + request: { + id: 1, + message, + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }), + ); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("should render all three action buttons", () => { + renderElicitationRequest(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /decline/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /submit/i }), + ).toBeInTheDocument(); + }); + + it("should render DynamicJsonForm component", () => { + renderElicitationRequest(); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + }); + + describe("User Interactions", () => { + it("should call onResolve with accept action when Submit button is clicked", async () => { + renderElicitationRequest(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { + value: '{"name": "John Doe", "email": "john@example.com"}', + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { + action: "accept", + content: { name: "John Doe", email: "john@example.com" }, + }); + }); + + it("should call onResolve with decline action when Decline button is clicked", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /decline/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" }); + }); + + it("should call onResolve with cancel action when Cancel button is clicked", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" }); + }); + }); + + describe("Form Validation", () => { + it("should show validation error for missing required fields", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect( + screen.getByText(/Required field missing: name/), + ).toBeInTheDocument(); + expect(mockOnResolve).not.toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: "accept" }), + ); + }); + + it("should show validation error for invalid email format", async () => { + renderElicitationRequest(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { value: '{"name": "John", "email": "invalid-email"}' }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect( + screen.getByText(/Invalid email format: email/), + ).toBeInTheDocument(); + expect(mockOnResolve).not.toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: "accept" }), + ); + }); + }); +}); diff --git a/client/src/components/__tests__/ElicitationTab.test.tsx b/client/src/components/__tests__/ElicitationTab.test.tsx new file mode 100644 index 00000000..ca519461 --- /dev/null +++ b/client/src/components/__tests__/ElicitationTab.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { Tabs } from "@/components/ui/tabs"; +import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab"; + +describe("Elicitation tab", () => { + const mockOnResolve = jest.fn(); + + const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) => + render( + + + , + ); + + it("should render 'No pending requests' when there are no pending requests", () => { + renderElicitationTab([]); + expect( + screen.getByText( + "When the server requests information from the user, requests will appear here for response.", + ), + ).toBeTruthy(); + expect(screen.findByText("No pending requests")).toBeTruthy(); + }); + + it("should render the correct number of requests", () => { + renderElicitationTab( + Array.from({ length: 3 }, (_, i) => ({ + id: i, + request: { + id: i, + message: `Please provide information ${i}`, + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Your name", + }, + }, + required: ["name"], + }, + }, + })), + ); + expect(screen.getAllByTestId("elicitation-request").length).toBe(3); + }); +}); diff --git a/client/src/components/__tests__/HistoryAndNotifications.test.tsx b/client/src/components/__tests__/HistoryAndNotifications.test.tsx new file mode 100644 index 00000000..42c58519 --- /dev/null +++ b/client/src/components/__tests__/HistoryAndNotifications.test.tsx @@ -0,0 +1,226 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, jest } from "@jest/globals"; +import HistoryAndNotifications from "../HistoryAndNotifications"; +import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; + +// Mock JsonView component +jest.mock("../JsonView", () => { + return function JsonView({ data }: { data: string }) { + return
      {data}
      ; + }; +}); + +describe("HistoryAndNotifications", () => { + const mockRequestHistory = [ + { + request: JSON.stringify({ method: "test/method1", params: {} }), + response: JSON.stringify({ result: "success" }), + }, + { + request: JSON.stringify({ method: "test/method2", params: {} }), + response: JSON.stringify({ result: "success" }), + }, + ]; + + const mockNotifications: ServerNotification[] = [ + { + method: "notifications/message", + params: { + level: "info" as const, + message: "First notification", + }, + }, + { + method: "notifications/progress", + params: { + progressToken: "test-token", + progress: 50, + message: "Second notification", + }, + }, + ]; + + it("renders history and notifications sections", () => { + render( + , + ); + + expect(screen.getByText("History")).toBeTruthy(); + expect(screen.getByText("Server Notifications")).toBeTruthy(); + }); + + it("displays request history items with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. test/method2")).toBeTruthy(); + expect(screen.getByText("1. test/method1")).toBeTruthy(); + }); + + it("displays server notifications with correct numbering", () => { + render( + , + ); + + // Items should be numbered in reverse order (newest first) + expect(screen.getByText("2. notifications/progress")).toBeTruthy(); + expect(screen.getByText("1. notifications/message")).toBeTruthy(); + }); + + it("expands and collapses request items when clicked", () => { + render( + , + ); + + const firstRequestHeader = screen.getByText("2. test/method2"); + + // Initially collapsed - should show ā–¶ arrows (there are multiple) + expect(screen.getAllByText("ā–¶")).toHaveLength(2); + expect(screen.queryByText("Request:")).toBeNull(); + + // Click to expand + fireEvent.click(firstRequestHeader); + + // Should now be expanded - one ā–¼ and one ā–¶ + expect(screen.getByText("ā–¼")).toBeTruthy(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + }); + + it("expands and collapses notification items when clicked", () => { + render( + , + ); + + const firstNotificationHeader = screen.getByText( + "2. notifications/progress", + ); + + // Initially collapsed + expect(screen.getAllByText("ā–¶")).toHaveLength(2); + expect(screen.queryByText("Details:")).toBeNull(); + + // Click to expand + fireEvent.click(firstNotificationHeader); + + // Should now be expanded + expect(screen.getByText("ā–¼")).toBeTruthy(); + expect(screen.getAllByText("ā–¶")).toHaveLength(1); + expect(screen.getByText("Details:")).toBeTruthy(); + }); + + it("maintains expanded state when new notifications are added", () => { + const { rerender } = render( + , + ); + + // Find and expand the older notification (should be "1. notifications/message") + const olderNotificationHeader = screen.getByText( + "1. notifications/message", + ); + fireEvent.click(olderNotificationHeader); + + // Verify it's expanded + expect(screen.getByText("Details:")).toBeTruthy(); + + // Add a new notification at the beginning (simulating real behavior) + const newNotifications: ServerNotification[] = [ + { + method: "notifications/resources/updated", + params: { uri: "file://test.txt" }, + }, + ...mockNotifications, + ]; + + // Re-render with new notifications + rerender( + , + ); + + // The original notification should still be expanded + // It's now numbered as "2. notifications/message" due to the new item + expect(screen.getByText("3. notifications/progress")).toBeTruthy(); + expect(screen.getByText("2. notifications/message")).toBeTruthy(); + expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy(); + + // The originally expanded notification should still show its details + expect(screen.getByText("Details:")).toBeTruthy(); + }); + + it("maintains expanded state when new requests are added", () => { + const { rerender } = render( + , + ); + + // Find and expand the older request (should be "1. test/method1") + const olderRequestHeader = screen.getByText("1. test/method1"); + fireEvent.click(olderRequestHeader); + + // Verify it's expanded + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + + // Add a new request at the beginning + const newRequestHistory = [ + { + request: JSON.stringify({ method: "test/new_method", params: {} }), + response: JSON.stringify({ result: "new success" }), + }, + ...mockRequestHistory, + ]; + + // Re-render with new request history + rerender( + , + ); + + // The original request should still be expanded + // It's now numbered as "2. test/method1" due to the new item + expect(screen.getByText("3. test/method2")).toBeTruthy(); + expect(screen.getByText("2. test/method1")).toBeTruthy(); + expect(screen.getByText("1. test/new_method")).toBeTruthy(); + + // The originally expanded request should still show its details + expect(screen.getByText("Request:")).toBeTruthy(); + expect(screen.getByText("Response:")).toBeTruthy(); + }); + + it("displays empty state messages when no data is available", () => { + render( + , + ); + + expect(screen.getByText("No history yet")).toBeTruthy(); + expect(screen.getByText("No notifications yet")).toBeTruthy(); + }); +}); diff --git a/client/src/components/__tests__/ResourcesTab.test.tsx b/client/src/components/__tests__/ResourcesTab.test.tsx new file mode 100644 index 00000000..d564b02c --- /dev/null +++ b/client/src/components/__tests__/ResourcesTab.test.tsx @@ -0,0 +1,308 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { Tabs } from "@/components/ui/tabs"; +import ResourcesTab from "../ResourcesTab"; +import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js"; + +// Mock the hooks and components +jest.mock("@/lib/hooks/useCompletionState", () => ({ + useCompletionState: () => ({ + completions: {}, + clearCompletions: jest.fn(), + requestCompletions: jest.fn(), + }), +})); + +jest.mock("../JsonView", () => { + return function MockJsonView({ data }: { data: string }) { + return
      {data}
      ; + }; +}); + +jest.mock("@/components/ui/combobox", () => ({ + Combobox: ({ + id, + value, + onChange, + placeholder, + }: { + id: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + }) => ( + onChange(e.target.value)} + placeholder={placeholder} + data-testid={`combobox-${id}`} + /> + ), +})); + +jest.mock("@/components/ui/label", () => ({ + Label: ({ + htmlFor, + children, + }: { + htmlFor: string; + children: React.ReactNode; + }) => ( + + ), +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + [key: string]: unknown; + }) => ( + + ), +})); + +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 47c19b17..e892a7f8 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -42,6 +42,10 @@ describe("Sidebar Environment Variables", () => { setArgs: jest.fn(), sseUrl: "", setSseUrl: jest.fn(), + oauthClientId: "", + setOauthClientId: jest.fn(), + oauthScope: "", + setOauthScope: jest.fn(), env: {}, setEnv: jest.fn(), bearerToken: "", @@ -519,6 +523,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 +549,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 +575,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 +598,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 +645,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 3000, + is_session_item: false, }, }), ); @@ -755,7 +764,7 @@ describe("Sidebar Environment Variables", () => { { type: "sse", url: sseUrl, - note: "For SSE connections, add this URL directly in Client", + note: "For SSE connections, add this URL directly in your MCP Client", }, null, 4, @@ -780,7 +789,7 @@ describe("Sidebar Environment Variables", () => { "default-server": { type: "sse", url: sseUrl, - note: "For SSE connections, add this URL directly in Client", + note: "For SSE connections, add this URL directly in your MCP Client", }, }, }, @@ -805,7 +814,7 @@ describe("Sidebar Environment Variables", () => { { type: "streamable-http", url: sseUrl, - note: "For Streamable HTTP connections, add this URL directly in Client", + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", }, null, 4, @@ -830,7 +839,7 @@ describe("Sidebar Environment Variables", () => { "default-server": { type: "streamable-http", url: sseUrl, - note: "For Streamable HTTP connections, add this URL directly in Client", + note: "For Streamable HTTP connections, add this URL directly in your MCP Client", }, }, }, diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index c46a32fc..bc75401d 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -1,11 +1,18 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; -import { describe, it, expect, jest } from "@jest/globals"; import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach } from "@jest/globals"; import ToolsTab from "../ToolsTab"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "@/components/ui/tabs"; +import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; +import { within } from "@testing-library/react"; describe("ToolsTab", () => { + beforeEach(() => { + // Clear the output schema cache before each test + cacheToolOutputSchemas([]); + }); + const mockTools: Tool[] = [ { name: "tool1", @@ -49,6 +56,8 @@ describe("ToolsTab", () => { toolResult: null, nextCursor: "", error: null, + resourceContent: {}, + onReadResource: jest.fn(), }; const renderToolsTab = (props = {}) => { @@ -105,6 +114,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; @@ -141,4 +171,470 @@ describe("ToolsTab", () => { expect(submitButton.getAttribute("disabled")).toBeNull(); }); + + describe("Output Schema Display", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: { + city: { type: "string" as const }, + }, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + humidity: { type: "number" as const }, + }, + required: ["temperature", "humidity"], + }, + }; + + it("should display output schema when tool has one", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + expect(screen.getByText("Output Schema:")).toBeInTheDocument(); + // Check for expand/collapse button + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + + it("should not display output schema section when tool doesn't have one", () => { + renderToolsTab({ + selectedTool: mockTools[0], // Tool without outputSchema + }); + + expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument(); + }); + + it("should toggle output schema expansion", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + const toggleButton = screen.getByRole("button", { name: /expand/i }); + + // Click to expand + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /collapse/i }), + ).toBeInTheDocument(); + + // Click to collapse + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + }); + + describe("Structured Output Results", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: {}, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + }, + required: ["temperature"], + }, + }; + + beforeEach(() => { + // Cache the tool's output schema before each test + cacheToolOutputSchemas([toolWithOutputSchema]); + }); + + it("should display structured content when present", () => { + const structuredResult = { + content: [], + structuredContent: { + temperature: 25, + }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: structuredResult, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect( + screen.getByText(/Valid according to output schema/), + ).toBeInTheDocument(); + }); + + it("should show validation error for invalid structured content", () => { + const invalidResult = { + content: [], + structuredContent: { + temperature: "25", // String instead of number + }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: invalidResult, + }); + + expect(screen.getByText(/Validation Error:/)).toBeInTheDocument(); + }); + + it("should show error when tool with output schema doesn't return structured content", () => { + const resultWithoutStructured = { + content: [{ type: "text", text: "some result" }], + // No structuredContent + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: resultWithoutStructured, + }); + + expect( + screen.getByText( + /Tool has an output schema but did not return structured content/, + ), + ).toBeInTheDocument(); + }); + + it("should show unstructured content title when both structured and unstructured exist", () => { + const resultWithBoth = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: resultWithBoth, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect(screen.getByText("Unstructured Content:")).toBeInTheDocument(); + }); + + it("should not show unstructured content title when only unstructured exists", () => { + const resultWithUnstructuredOnly = { + content: [{ type: "text", text: "some result" }], + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithUnstructuredOnly, + }); + + expect( + screen.queryByText("Unstructured Content:"), + ).not.toBeInTheDocument(); + }); + + it("should show compatibility check when tool has output schema", () => { + const compatibleResult = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: compatibleResult, + }); + + // Should show compatibility result + expect( + screen.getByText(/structured content matches/i), + ).toBeInTheDocument(); + }); + + it("should accept multiple content blocks with structured output", () => { + const multipleBlocksResult = { + content: [ + { type: "text", text: "Here is the weather data:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "text", text: "Have a nice day!" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: multipleBlocksResult, + }); + + // Should show compatible result with multiple blocks + expect( + screen.getByText(/structured content matches.*multiple/i), + ).toBeInTheDocument(); + }); + + it("should accept mixed content types with structured output", () => { + const mixedContentResult = { + content: [ + { type: "text", text: "Weather report:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: mixedContentResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks match structured content", () => { + const noMatchResult = { + content: [ + { type: "text", text: "Some text" }, + { type: "text", text: '{"humidity": 60}' }, // Different structure + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noMatchResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks are present", () => { + const noTextBlocksResult = { + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noTextBlocksResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should not show compatibility check when tool has no output schema", () => { + const resultWithBoth = { + content: [{ type: "text", text: '{"data": "value"}' }], + structuredContent: { different: "data" }, + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithBoth, + }); + + // Should not show any compatibility messages + expect( + screen.queryByText( + /structured content matches|no text blocks|no.*matches/i, + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Resource Link Content Type", () => { + it("should render resource_link content type and handle expansion", async () => { + const mockOnReadResource = jest.fn(); + const resourceContent = { + "test://static/resource/1": JSON.stringify({ + contents: [ + { + uri: "test://static/resource/1", + name: "Resource 1", + mimeType: "text/plain", + text: "Resource 1: This is a plaintext resource", + }, + ], + }), + }; + + const result = { + content: [ + { + type: "resource_link", + uri: "test://static/resource/1", + name: "Resource 1", + description: "Resource 1: plaintext resource", + mimeType: "text/plain", + }, + { + type: "resource_link", + uri: "test://static/resource/2", + name: "Resource 2", + description: "Resource 2: binary blob resource", + mimeType: "application/octet-stream", + }, + { + type: "resource_link", + uri: "test://static/resource/3", + name: "Resource 3", + description: "Resource 3: plaintext resource", + mimeType: "text/plain", + }, + ], + }; + + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: result, + resourceContent, + onReadResource: mockOnReadResource, + }); + + ["1", "2", "3"].forEach((id) => { + expect( + screen.getByText(`test://static/resource/${id}`), + ).toBeInTheDocument(); + expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument(); + }); + + expect(screen.getAllByText("text/plain")).toHaveLength(2); + expect(screen.getByText("application/octet-stream")).toBeInTheDocument(); + + const expandButtons = screen.getAllByRole("button", { + name: /expand resource/i, + }); + expect(expandButtons).toHaveLength(3); + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + + expandButtons.forEach((button) => { + expect(button).toHaveAttribute("aria-expanded", "false"); + }); + + const resource1Button = screen.getByRole("button", { + name: /expand resource test:\/\/static\/resource\/1/i, + }); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(mockOnReadResource).toHaveBeenCalledWith( + "test://static/resource/1", + ); + expect(screen.getByText("Resource:")).toBeInTheDocument(); + expect(document.body).toHaveTextContent("contents:"); + expect(document.body).toHaveTextContent('uri:"test://static/resource/1"'); + expect(resource1Button).toHaveAttribute("aria-expanded", "true"); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("contents:"); + expect(document.body).not.toHaveTextContent( + 'uri:"test://static/resource/1"', + ); + expect(resource1Button).toHaveAttribute("aria-expanded", "false"); + expect(mockOnReadResource).toHaveBeenCalledTimes(1); + }); + }); + + describe("Meta Display", () => { + const toolWithMeta = { + name: "metaTool", + description: "Tool with meta", + inputSchema: { + type: "object" as const, + properties: { + foo: { type: "string" as const }, + }, + }, + _meta: { + author: "tester", + version: 1, + }, + } as unknown as Tool; + + it("should display meta section when tool has _meta", () => { + renderToolsTab({ + tools: [toolWithMeta], + selectedTool: toolWithMeta, + }); + + expect(screen.getByText("Meta:")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + + it("should toggle meta expansion", () => { + renderToolsTab({ + tools: [toolWithMeta], + selectedTool: toolWithMeta, + }); + + // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section + const metaHeading = screen.getByText("Meta:"); + const metaContainer = metaHeading.closest("div"); + expect(metaContainer).toBeTruthy(); + const toggleButton = within(metaContainer as HTMLElement).getByRole( + "button", + { name: /expand/i }, + ); + + // Expand Meta + fireEvent.click(toggleButton); + expect( + within(metaContainer as HTMLElement).getByRole("button", { + name: /collapse/i, + }), + ).toBeInTheDocument(); + + // Collapse Meta + fireEvent.click(toggleButton); + expect( + within(metaContainer as HTMLElement).getByRole("button", { + name: /expand/i, + }), + ).toBeInTheDocument(); + }); + }); + + describe("ToolResults Meta", () => { + it("should display meta information when present in toolResult", () => { + const resultWithMeta = { + content: [], + _meta: { info: "details", version: 2 }, + }; + + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: resultWithMeta, + }); + + // Only ToolResults meta should be present since selectedTool has no _meta + expect(screen.getAllByText("Meta:")).toHaveLength(1); + expect(screen.getByText(/info/i)).toBeInTheDocument(); + expect(screen.getByText(/version/i)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/index.css b/client/src/index.css index 11c6f23d..858f9e7f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -93,8 +93,8 @@ h1 { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --border: 217.2 24% 24%; + --input: 217.2 24% 24%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; diff --git a/client/src/lib/__tests__/auth.test.ts b/client/src/lib/__tests__/auth.test.ts new file mode 100644 index 00000000..329b7f02 --- /dev/null +++ b/client/src/lib/__tests__/auth.test.ts @@ -0,0 +1,155 @@ +import { discoverScopes } from "../auth"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; + +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + discoverAuthorizationServerMetadata: jest.fn(), +})); + +const mockDiscoverAuth = + discoverAuthorizationServerMetadata as jest.MockedFunction< + typeof discoverAuthorizationServerMetadata + >; + +const baseMetadata = { + issuer: "https://test.com", + authorization_endpoint: "https://test.com/authorize", + token_endpoint: "https://test.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + scopes_supported: ["read", "write"], +}; + +describe("discoverScopes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases = [ + { + name: "returns joined scopes from OAuth metadata", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + expected: "read write", + expectedCallUrl: "https://example.com/", + }, + { + name: "prefers resource metadata over OAuth metadata", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["admin", "full"], + }, + expected: "admin full", + }, + { + name: "falls back to OAuth when resource has empty scopes", + mockResolves: baseMetadata, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: [], + }, + expected: "read write", + }, + { + name: "normalizes URL with port and path", + mockResolves: baseMetadata, + serverUrl: "https://example.com:8080/some/path", + expected: "read write", + expectedCallUrl: "https://example.com:8080/", + }, + { + name: "normalizes URL with trailing slash", + mockResolves: baseMetadata, + serverUrl: "https://example.com/", + expected: "read write", + expectedCallUrl: "https://example.com/", + }, + { + name: "handles single scope", + mockResolves: { ...baseMetadata, scopes_supported: ["admin"] }, + serverUrl: "https://example.com", + expected: "admin", + }, + { + name: "prefers resource metadata even with fewer scopes", + mockResolves: { + ...baseMetadata, + scopes_supported: ["read", "write", "admin", "full"], + }, + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["read"], + }, + expected: "read", + }, + ]; + + const undefinedCases = [ + { + name: "returns undefined when OAuth discovery fails", + mockRejects: new Error("Discovery failed"), + serverUrl: "https://example.com", + }, + { + name: "returns undefined when OAuth has no scopes", + mockResolves: { ...baseMetadata, scopes_supported: [] }, + serverUrl: "https://example.com", + }, + { + name: "returns undefined when scopes_supported missing", + mockResolves: (() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { scopes_supported, ...rest } = baseMetadata; + return rest; + })(), + serverUrl: "https://example.com", + }, + { + name: "returns undefined with resource metadata but OAuth fails", + mockRejects: new Error("No OAuth metadata"), + serverUrl: "https://example.com", + resourceMetadata: { + resource: "https://example.com", + scopes_supported: ["read", "write"], + }, + }, + ]; + + test.each(testCases)( + "$name", + async ({ + mockResolves, + serverUrl, + resourceMetadata, + expected, + expectedCallUrl, + }) => { + mockDiscoverAuth.mockResolvedValue(mockResolves); + + const result = await discoverScopes(serverUrl, resourceMetadata); + + expect(result).toBe(expected); + if (expectedCallUrl) { + expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl)); + } + }, + ); + + test.each(undefinedCases)( + "$name", + async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => { + if (mockRejects) { + mockDiscoverAuth.mockRejectedValue(mockRejects); + } else { + mockDiscoverAuth.mockResolvedValue(mockResolves); + } + + const result = await discoverScopes(serverUrl, resourceMetadata); + + expect(result).toBeUndefined(); + }, + ); +}); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts 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/auth.ts b/client/src/lib/auth.ts index 3e3516e0..99707387 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -6,11 +6,103 @@ import { OAuthTokensSchema, OAuthClientMetadata, OAuthMetadata, + OAuthProtectedResourceMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +import { generateOAuthState } from "@/utils/oauthUtils"; + +/** + * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes + * @param serverUrl - The MCP server URL + * @param resourceMetadata - Optional resource metadata containing preferred scopes + * @returns Promise resolving to space-separated scope string or undefined + */ +export const discoverScopes = async ( + serverUrl: string, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise => { + try { + const metadata = await discoverAuthorizationServerMetadata( + new URL("/", serverUrl), + ); + + // Prefer resource metadata scopes, but fall back to OAuth metadata if empty + const resourceScopes = resourceMetadata?.scopes_supported; + const oauthScopes = metadata?.scopes_supported; + + const scopesSupported = + resourceScopes && resourceScopes.length > 0 + ? resourceScopes + : oauthScopes; + + return scopesSupported && scopesSupported.length > 0 + ? scopesSupported.join(" ") + : undefined; + } catch (error) { + console.debug("OAuth scope discovery failed:", error); + return undefined; + } +}; + +export const getClientInformationFromSessionStorage = async ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + + const value = sessionStorage.getItem(key); + if (!value) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); +}; + +export const saveClientInformationToSessionStorage = ({ + serverUrl, + clientInformation, + isPreregistered, +}: { + serverUrl: string; + clientInformation: OAuthClientInformation; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(clientInformation)); +}; + +export const clearClientInformationFromSessionStorage = ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.removeItem(key); +}; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(public serverUrl: string) { + constructor(protected serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } @@ -30,25 +122,42 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { }; } - async clientInformation() { - const key = getServerSpecificKey( - SESSION_KEYS.CLIENT_INFORMATION, - this.serverUrl, - ); - const value = sessionStorage.getItem(key); - if (!value) { - return undefined; - } + state(): string | Promise { + return generateOAuthState(); + } - return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); + async clientInformation() { + // Try to get the preregistered client information from session storage first + const preregisteredClientInformation = + await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: true, + }); + + // If no preregistered client information is found, get the dynamically registered client information + return ( + preregisteredClientInformation ?? + (await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + })) + ); } saveClientInformation(clientInformation: OAuthClientInformation) { - const key = getServerSpecificKey( - SESSION_KEYS.CLIENT_INFORMATION, - this.serverUrl, - ); - sessionStorage.setItem(key, JSON.stringify(clientInformation)); + // Remove client_secret before storing (not needed after initial OAuth flow) + const safeInfo = Object.fromEntries( + Object.entries(clientInformation).filter( + ([key]) => key !== "client_secret", + ), + ) as OAuthClientInformation; + + // Save the dynamically registered client information to session storage + saveClientInformationToSessionStorage({ + serverUrl: this.serverUrl, + clientInformation: safeInfo, + isPreregistered: false, + }); } async tokens() { @@ -67,6 +176,12 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { + if ( + authorizationUrl.protocol !== "http:" && + authorizationUrl.protocol !== "https:" + ) { + throw new Error("Authorization URL must be HTTP or HTTPS"); + } window.location.href = authorizationUrl.href; } @@ -92,9 +207,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } clear() { - sessionStorage.removeItem( - getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl), - ); + clearClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + }); sessionStorage.removeItem( getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), ); 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..2e983302 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,7 +6,9 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", + PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", SERVER_METADATA: "mcp_server_metadata", + AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", } as const; // Generate server-specific session storage keys @@ -35,22 +37,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 c6700dcf..7518f814 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -3,6 +3,16 @@ import { useConnection } from "../useConnection"; import { z } from "zod"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; +import { + SSEClientTransportOptions, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; +import { + ElicitResult, + ElicitRequest, +} from "@modelcontextprotocol/sdk/types.js"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { discoverScopes } from "../../auth"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ @@ -23,13 +33,59 @@ 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(), - SseError: jest.fn(), +jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => { + // Minimal mock class that supports instanceof checks + class SseError extends Error { + code: number; + event: ErrorEvent; + constructor(code: number, message: string, event: ErrorEvent) { + super(message); + this.code = code; + this.event = event; + } + } + + return { + SSEClientTransport: jest.fn((url, options) => { + mockSSETransport.url = url; + mockSSETransport.options = options; + return mockSSETransport; + }), + SseError, + }; +}); + +jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: jest.fn((url, options) => { + mockStreamableHTTPTransport.url = url; + mockStreamableHTTPTransport.options = options; + return mockStreamableHTTPTransport; + }), })); jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ @@ -47,9 +103,18 @@ jest.mock("@/lib/hooks/useToast", () => ({ jest.mock("../../auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }), + redirectUrl: "http://localhost:3000/oauth/callback", })), + clearClientInformationFromSessionStorage: jest.fn(), + saveClientInformationToSessionStorage: jest.fn(), + discoverScopes: jest.fn(), })); +const mockAuth = auth as jest.MockedFunction; +const mockDiscoverScopes = discoverScopes as jest.MockedFunction< + typeof discoverScopes +>; + describe("useConnection", () => { const defaultProps = { transportType: "sse" as const, @@ -163,4 +228,653 @@ describe("useConnection", () => { result.current.makeRequest(mockRequest, mockSchema), ).rejects.toThrow("MCP client not connected"); }); + + describe("Elicitation Support", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("declares elicitation capability during client initialization", async () => { + const Client = jest.requireMock( + "@modelcontextprotocol/sdk/client/index.js", + ).Client; + + const { result } = renderHook(() => useConnection(defaultProps)); + + await act(async () => { + await result.current.connect(); + }); + + expect(Client).toHaveBeenCalledWith( + expect.objectContaining({ + name: "mcp-inspector", + version: expect.any(String), + }), + expect.objectContaining({ + capabilities: expect.objectContaining({ + elicitation: {}, + }), + }), + ); + }); + + test("sets up elicitation request handler when onElicitationRequest is provided", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeDefined(); + expect(mockClient.setRequestHandler).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + ); + }); + + test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => { + const { result } = renderHook(() => useConnection(defaultProps)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeUndefined(); + }); + + test("elicitation request handler calls onElicitationRequest callback", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeDefined(); + const [, handler] = elicitRequestHandlerCall; + + const mockElicitationRequest: ElicitRequest = { + method: "elicitation/create", + params: { + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + }; + + mockOnElicitationRequest.mockImplementation((_request, resolve) => { + resolve({ action: "accept", content: { name: "test" } }); + }); + + await act(async () => { + await handler(mockElicitationRequest); + }); + + expect(mockOnElicitationRequest).toHaveBeenCalledWith( + mockElicitationRequest, + expect.any(Function), + ); + }); + + test("elicitation request handler returns a promise that resolves with the callback result", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + const [, handler] = elicitRequestHandlerCall; + + const mockElicitationRequest: ElicitRequest = { + method: "elicitation/create", + params: { + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + }; + + const mockResponse: ElicitResult = { + action: "accept", + content: { name: "John Doe" }, + }; + + mockOnElicitationRequest.mockImplementation((_request, resolve) => { + resolve(mockResponse); + }); + + let handlerResult; + await act(async () => { + handlerResult = await handler(mockElicitationRequest); + }); + + expect(handlerResult).toEqual(mockResponse); + }); + }); + + describe("URL Port Handling", () => { + const SSEClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/sse.js", + ).SSEClientTransport; + const StreamableHTTPClientTransport = jest.requireMock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + ).StreamableHTTPClientTransport; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("preserves HTTPS port number when connecting", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com:8443/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain( + "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", + ); + }); + + test("preserves HTTP port number when connecting", async () => { + const props = { + ...defaultProps, + sseUrl: "http://localhost:3000/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain( + "url=http%3A%2F%2Flocalhost%3A3000%2Fapi", + ); + }); + + test("uses default port for HTTPS when not specified", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com/api", + transportType: "sse" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = SSEClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi"); + expect(call.toString()).not.toContain("%3A443"); + }); + + test("preserves port number in streamable-http transport", async () => { + const props = { + ...defaultProps, + sseUrl: "https://example.com:8443/api", + transportType: "streamable-http" as const, + }; + + const { result } = renderHook(() => useConnection(props)); + + await act(async () => { + await result.current.connect(); + }); + + const call = StreamableHTTPClientTransport.mock.calls[0][0]; + expect(call.toString()).toContain( + "url=https%3A%2F%2Fexample.com%3A8443%2Fapi", + ); + }); + }); + + describe("Proxy Authentication Headers", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the mock transport objects + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.url = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", 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"); + }); + }); + + describe("OAuth Error Handling with Scope Discovery", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAuth.mockResolvedValue("AUTHORIZED"); + mockDiscoverScopes.mockResolvedValue(undefined); + }); + + const setup401Error = () => { + const mockErrorEvent = new ErrorEvent("error", { + message: "Mock error event", + }); + mockClient.connect.mockRejectedValueOnce( + new SseError(401, "Unauthorized", mockErrorEvent), + ); + }; + + const attemptConnection = async (props = defaultProps) => { + const { result } = renderHook(() => useConnection(props)); + await act(async () => { + try { + await result.current.connect(); + } catch { + // Expected error from auth handling + } + }); + }; + + const testCases = [ + [ + "discovers and includes scopes in auth call", + { + discoveredScope: "read write admin", + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: "read write admin", + authResult: "AUTHORIZED", + }, + ], + [ + "handles scope discovery failure gracefully", + { + discoveredScope: undefined, + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: undefined, + authResult: "AUTHORIZED", + }, + ], + [ + "uses manual oauthScope override instead of discovered scopes", + { + discoveredScope: "discovered:scope", + oauthScope: "manual:scope", + expectScopeCall: false, + expectedAuthScope: "manual:scope", + authResult: "AUTHORIZED", + }, + ], + [ + "triggers scope discovery when oauthScope is whitespace", + { + discoveredScope: "discovered:scope", + oauthScope: " ", + expectScopeCall: true, + expectedAuthScope: "discovered:scope", + authResult: "AUTHORIZED", + }, + ], + [ + "handles auth failure after scope discovery", + { + discoveredScope: "read write", + oauthScope: undefined, + expectScopeCall: true, + expectedAuthScope: "read write", + authResult: "UNAUTHORIZED", + }, + ], + ] as const; + + test.each(testCases)( + "should %s", + async ( + _, + { + discoveredScope, + oauthScope, + expectScopeCall, + expectedAuthScope, + authResult = "AUTHORIZED", + }, + ) => { + mockDiscoverScopes.mockResolvedValue(discoveredScope); + mockAuth.mockResolvedValue(authResult as never); + setup401Error(); + + const props = + oauthScope !== undefined + ? { ...defaultProps, oauthScope } + : defaultProps; + await attemptConnection(props); + + if (expectScopeCall) { + expect(mockDiscoverScopes).toHaveBeenCalledWith( + defaultProps.sseUrl, + undefined, + ); + } else { + expect(mockDiscoverScopes).not.toHaveBeenCalled(); + } + + expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { + serverUrl: defaultProps.sseUrl, + scope: expectedAuthScope, + }); + }, + ); + + it("should handle slow scope discovery gracefully", async () => { + mockDiscoverScopes.mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve(undefined), 100)), + ); + + setup401Error(); + await attemptConnection(); + + expect(mockDiscoverScopes).toHaveBeenCalledWith( + defaultProps.sseUrl, + undefined, + ); + expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), { + serverUrl: defaultProps.sseUrl, + scope: undefined, + }); + }); + }); }); diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts index 71492e31..26b69a27 100644 --- a/client/src/lib/hooks/useCompletionState.ts +++ b/client/src/lib/hooks/useCompletionState.ts @@ -28,6 +28,7 @@ export function useCompletionState( ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, signal?: AbortSignal, ) => Promise, completionsSupported: boolean = true, @@ -66,6 +67,7 @@ export function useCompletionState( ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, ) => { if (!completionsSupported) { return; @@ -82,10 +84,15 @@ export function useCompletionState( })); try { + if (context !== undefined) { + delete context[argName]; + } + const values = await handleCompletion( ref, argName, value, + context, abortController.signal, ); @@ -97,6 +104,7 @@ export function useCompletionState( })); } } catch { + console.error("completion failed"); if (!abortController.signal.aborted) { setState((prev) => ({ ...prev, diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 4ced90e4..e6008197 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -28,23 +28,35 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, Progress, + LoggingLevel, + ElicitRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { InspectorOAuthClientProvider } from "../auth"; +import { + auth, + discoverOAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + clearClientInformationFromSessionStorage, + InspectorOAuthClientProvider, + saveClientInformationToSessionStorage, + discoverScopes, +} from "../auth"; import packageJson from "../../../package.json"; 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"; @@ -54,13 +66,18 @@ interface UseConnectionOptions { env: Record; bearerToken?: string; headerName?: string; + oauthClientId?: string; + oauthScope?: string; config: InspectorConfig; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any onPendingRequest?: (request: any, resolve: any, reject: any) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any + onElicitationRequest?: (request: any, resolve: any) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any getRoots?: () => any[]; + defaultLoggingLevel?: LoggingLevel; } export function useConnection({ @@ -71,11 +88,15 @@ export function useConnection({ env, bearerToken, headerName, + oauthClientId, + oauthScope, config, onNotification, onStdErrNotification, onPendingRequest, + onElicitationRequest, getRoots, + defaultLoggingLevel, }: UseConnectionOptions) { const [connectionStatus, setConnectionStatus] = useState("disconnected"); @@ -83,10 +104,29 @@ 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 }[] >([]); - const [completionsSupported, setCompletionsSupported] = useState(true); + const [completionsSupported, setCompletionsSupported] = useState(false); + + useEffect(() => { + if (!oauthClientId) { + clearClientInformationFromSessionStorage({ + serverUrl: sseUrl, + isPreregistered: true, + }); + return; + } + + saveClientInformationToSessionStorage({ + serverUrl: sseUrl, + clientInformation: { client_id: oauthClientId }, + isPreregistered: true, + }); + }, [oauthClientId, sseUrl]); const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ @@ -165,6 +205,7 @@ export function useConnection({ ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, signal?: AbortSignal, ): Promise => { if (!mcpClient || !completionsSupported) { @@ -182,6 +223,12 @@ export function useConnection({ }, }; + if (context) { + request["params"]["context"] = { + arguments: context, + }; + } + try { const response = await makeRequest(request, CompleteResultSchema, { signal, @@ -238,7 +285,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"); @@ -249,12 +302,43 @@ export function useConnection({ } }; + const is401Error = (error: unknown): boolean => { + return ( + (error instanceof SseError && error.code === 401) || + (error instanceof Error && error.message.includes("401")) || + (error instanceof Error && error.message.includes("Unauthorized")) + ); + }; + + const isProxyAuthError = (error: unknown): boolean => { + return ( + error instanceof Error && + error.message.includes("Authentication required. Use the session token") + ); + }; + const handleAuthError = async (error: unknown) => { - if (error instanceof SseError && error.code === 401) { - // Create a new auth provider with the current server URL + if (is401Error(error)) { const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - const result = await auth(serverAuthProvider, { serverUrl: sseUrl }); + let scope = oauthScope?.trim(); + if (!scope) { + // Only discover resource metadata when we need to discover scopes + let resourceMetadata; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + new URL("/", sseUrl), + ); + } catch { + // Resource metadata is optional, continue without it + } + scope = await discoverScopes(sseUrl, resourceMetadata); + } + + const result = await auth(serverAuthProvider, { + serverUrl: sseUrl, + scope, + }); return result === "AUTHORIZED"; } @@ -270,6 +354,7 @@ export function useConnection({ { capabilities: { sampling: {}, + elicitation: {}, roots: { listChanged: true, }, @@ -284,6 +369,7 @@ export function useConnection({ return; } + let lastRequest = ""; try { // Inject auth manually instead of using SSEClientTransport, because we're // proxying through the inspector server first. @@ -297,7 +383,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 @@ -317,11 +418,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; @@ -330,15 +435,18 @@ export function useConnection({ mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { - authProvider: serverAuthProvider, 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; @@ -347,15 +455,18 @@ export function useConnection({ mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); mcpProxyServerUrl.searchParams.append("url", sseUrl); transportOptions = { - authProvider: serverAuthProvider, 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: { @@ -372,14 +483,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, @@ -409,7 +512,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 = { @@ -425,19 +541,32 @@ export function useConnection({ `Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`, error, ); + + // Check if it's a proxy auth error + if (isProxyAuthError(error)) { + toast({ + title: "Proxy Authentication Required", + description: + "Please enter the session token from the proxy server console in the Configuration settings.", + variant: "destructive", + }); + setConnectionStatus("error"); + return; + } + const shouldRetry = await handleAuthError(error); if (shouldRetry) { return connect(undefined, retryCount + 1); } - - if (error instanceof SseError && error.code === 401) { + if (is401Error(error)) { // Don't set error state if we're about to redirect for auth + return; } throw error; } setServerCapabilities(capabilities ?? null); - setCompletionsSupported(true); // Reset completions support on new connection + setCompletionsSupported(capabilities?.completions !== undefined); if (onPendingRequest) { client.setRequestHandler(CreateMessageRequestSchema, (request) => { @@ -453,19 +582,49 @@ export function useConnection({ }); } + if (capabilities?.logging && defaultLoggingLevel) { + lastRequest = "logging/setLevel"; + await client.setLoggingLevel(defaultLoggingLevel); + lastRequest = ""; + } + + if (onElicitationRequest) { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + return new Promise((resolve) => { + onElicitationRequest(request, resolve); + }); + }); + } + setMcpClient(client); setConnectionStatus("connected"); } catch (e) { + if ( + lastRequest === "logging/setLevel" && + e instanceof McpError && + e.code === ErrorCode.MethodNotFound + ) { + toast({ + title: "Error", + description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`, + variant: "destructive", + }); + } console.error(e); setConnectionStatus("error"); } }; 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..4a2c0f47 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -1,12 +1,18 @@ import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider } from "./auth"; +import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; import { - discoverOAuthMetadata, + discoverAuthorizationServerMetadata, 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"; +import { generateOAuthState } from "@/utils/oauthUtils"; export interface StateMachineContext { state: AuthDebuggerState; @@ -18,7 +24,6 @@ export interface StateMachineContext { export interface StateTransition { canTransition: (context: StateMachineContext) => Promise; execute: (context: StateMachineContext) => Promise; - nextStep: OAuthStep; } // State machine transitions @@ -26,18 +31,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 discoverAuthorizationServerMetadata(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,23 +80,30 @@ 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, { - metadata, - clientMetadata, - }); + // Try Static client first, with DCR as fallback + let fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { + fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + context.provider.saveClientInformation(fullInformation); + } - context.provider.saveClientInformation(fullInformation); context.updateState({ oauthClientInfo: fullInformation, oauthStep: "authorization_redirect", }); }, - nextStep: "authorization_redirect", }, authorization_redirect: { @@ -72,10 +113,10 @@ export const oauthTransitions: Record = { const metadata = context.state.oauthMetadata!; const clientInformation = context.state.oauthClientInfo!; - let scope: string | undefined = undefined; - if (metadata.scopes_supported) { - scope = metadata.scopes_supported.join(" "); - } + const scope = await discoverScopes( + context.serverUrl, + context.state.resourceMetadata ?? undefined, + ); const { authorizationUrl, codeVerifier } = await startAuthorization( context.serverUrl, @@ -84,6 +125,8 @@ export const oauthTransitions: Record = { clientInformation, redirectUrl: context.provider.redirectUrl, scope, + state: generateOAuthState(), + resource: context.state.resource ?? undefined, }, ); @@ -93,7 +136,6 @@ export const oauthTransitions: Record = { oauthStep: "authorization_code", }); }, - nextStep: "authorization_code", }, authorization_code: { @@ -114,7 +156,6 @@ export const oauthTransitions: Record = { oauthStep: "token_request", }); }, - nextStep: "token_request", }, token_request: { @@ -136,6 +177,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 +186,6 @@ export const oauthTransitions: Record = { oauthStep: "complete", }); }, - nextStep: "complete", }, complete: { @@ -152,7 +193,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__/jsonUtils.test.ts b/client/src/utils/__tests__/jsonUtils.test.ts index 055e1dfd..42938eb5 100644 --- a/client/src/utils/__tests__/jsonUtils.test.ts +++ b/client/src/utils/__tests__/jsonUtils.test.ts @@ -4,7 +4,7 @@ import { updateValueAtPath, getValueAtPath, } from "../jsonUtils"; -import type { JsonValue } from "../jsonUtils"; +import type { JsonValue, JsonSchemaType } from "../jsonUtils"; describe("getDataType", () => { test("should return 'string' for string values", () => { @@ -317,3 +317,210 @@ describe("getValueAtPath", () => { expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane"); }); }); + +describe("JsonSchemaType elicitation field support", () => { + const sampleSchema: JsonSchemaType = { + type: "object", + title: "User Info", + description: "User information form", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Your full name", + minLength: 2, + maxLength: 50, + pattern: "^[A-Za-z\\s]+$", + }, + email: { + type: "string", + format: "email", + title: "Email Address", + }, + age: { + type: "integer", + minimum: 18, + maximum: 120, + default: 25, + }, + role: { + type: "string", + oneOf: [ + { const: "admin", title: "Administrator" }, + { const: "user", title: "User" }, + { const: "guest", title: "Guest" }, + ], + }, + }, + required: ["name", "email"], + }; + + test("should parse JsonSchemaType with elicitation fields", () => { + const schemaString = JSON.stringify(sampleSchema); + const result = tryParseJson(schemaString); + + expect(result.success).toBe(true); + expect(result.data).toEqual(sampleSchema); + }); + + test("should update schema properties with new validation fields", () => { + const updated = updateValueAtPath( + sampleSchema, + ["properties", "name", "minLength"], + 5, + ); + + expect(getValueAtPath(updated, ["properties", "name", "minLength"])).toBe( + 5, + ); + }); + + test("should handle oneOf with const and title fields", () => { + const schema = { + type: "string", + oneOf: [ + { const: "option1", title: "Option 1" }, + { const: "option2", title: "Option 2" }, + ], + }; + + expect(getValueAtPath(schema, ["oneOf", "0", "const"])).toBe("option1"); + expect(getValueAtPath(schema, ["oneOf", "1", "title"])).toBe("Option 2"); + }); + + test("should handle validation constraints", () => { + const numberSchema = { + type: "number" as const, + minimum: 0, + maximum: 100, + default: 50, + }; + + expect(getValueAtPath(numberSchema, ["minimum"])).toBe(0); + expect(getValueAtPath(numberSchema, ["maximum"])).toBe(100); + expect(getValueAtPath(numberSchema, ["default"])).toBe(50); + }); + + test("should handle string format and pattern fields", () => { + const stringSchema = { + type: "string" as const, + format: "email", + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + minLength: 5, + maxLength: 100, + }; + + expect(getValueAtPath(stringSchema, ["format"])).toBe("email"); + expect(getValueAtPath(stringSchema, ["pattern"])).toBe( + "^[a-z]+@[a-z]+\\.[a-z]+$", + ); + expect(getValueAtPath(stringSchema, ["minLength"])).toBe(5); + }); + + test("should handle title and description fields", () => { + const schema = { + type: "boolean" as const, + title: "Accept Terms", + description: "Do you accept the terms and conditions?", + default: false, + }; + + expect(getValueAtPath(schema, ["title"])).toBe("Accept Terms"); + expect(getValueAtPath(schema, ["description"])).toBe( + "Do you accept the terms and conditions?", + ); + }); + + test("should handle JSON Schema spec compliant oneOf with const for labeled enums", () => { + // Example from JSON Schema spec: labeled enums using oneOf with const + const trafficLightSchema = { + type: "string" as const, + title: "Traffic Light", + description: "Select a traffic light color", + oneOf: [ + { const: "red", title: "Stop" }, + { const: "amber", title: "Caution" }, + { const: "green", title: "Go" }, + ], + }; + + // Verify the schema structure + expect(trafficLightSchema.type).toBe("string"); + expect(trafficLightSchema.oneOf).toHaveLength(3); + + // Verify each oneOf option has const and title + expect(trafficLightSchema.oneOf[0].const).toBe("red"); + expect(trafficLightSchema.oneOf[0].title).toBe("Stop"); + + expect(trafficLightSchema.oneOf[1].const).toBe("amber"); + expect(trafficLightSchema.oneOf[1].title).toBe("Caution"); + + expect(trafficLightSchema.oneOf[2].const).toBe("green"); + expect(trafficLightSchema.oneOf[2].title).toBe("Go"); + + // Test with JsonValue operations + const schemaAsJsonValue = trafficLightSchema as JsonValue; + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "0", "const"])).toBe( + "red", + ); + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "1", "title"])).toBe( + "Caution", + ); + expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "2", "const"])).toBe( + "green", + ); + }); + + test("should handle complex oneOf scenarios with mixed schema types", () => { + const complexSchema = { + type: "object" as const, + title: "User Preference", + properties: { + theme: { + type: "string" as const, + oneOf: [ + { const: "light", title: "Light Mode" }, + { const: "dark", title: "Dark Mode" }, + { const: "auto", title: "Auto (System)" }, + ], + }, + notifications: { + type: "string" as const, + oneOf: [ + { const: "all", title: "All Notifications" }, + { const: "important", title: "Important Only" }, + { const: "none", title: "None" }, + ], + }, + }, + }; + + expect( + getValueAtPath(complexSchema, [ + "properties", + "theme", + "oneOf", + "0", + "const", + ]), + ).toBe("light"); + expect( + getValueAtPath(complexSchema, [ + "properties", + "theme", + "oneOf", + "1", + "title", + ]), + ).toBe("Dark Mode"); + expect( + getValueAtPath(complexSchema, [ + "properties", + "notifications", + "oneOf", + "2", + "const", + ]), + ).toBe("none"); + }); +}); diff --git a/client/src/utils/__tests__/oauthUtils.ts b/client/src/utils/__tests__/oauthUtils.test.ts similarity index 92% rename from client/src/utils/__tests__/oauthUtils.ts rename to client/src/utils/__tests__/oauthUtils.test.ts index cc9674cb..c42f014e 100644 --- a/client/src/utils/__tests__/oauthUtils.ts +++ b/client/src/utils/__tests__/oauthUtils.test.ts @@ -1,6 +1,7 @@ import { generateOAuthErrorDescription, parseOAuthCallbackParams, + generateOAuthState, } from "@/utils/oauthUtils.ts"; describe("parseOAuthCallbackParams", () => { @@ -75,4 +76,11 @@ describe("generateOAuthErrorDescription", () => { "Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.", ); }); + + describe("generateOAuthState", () => { + it("Returns a string", () => { + expect(generateOAuthState()).toBeDefined(); + expect(generateOAuthState()).toHaveLength(64); + }); + }); }); diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index 94e428ac..0ecd8075 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -1,74 +1,79 @@ -import { generateDefaultValue, formatFieldLabel } from "../schemaUtils"; +import { + generateDefaultValue, + formatFieldLabel, + normalizeUnionType, + cacheToolOutputSchemas, + getToolOutputValidator, + validateToolOutput, + hasOutputSchema, +} from "../schemaUtils"; import type { JsonSchemaType } from "../jsonUtils"; +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({ @@ -81,18 +86,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" }, }, }, }, @@ -137,3 +142,403 @@ describe("formatFieldLabel", () => { expect(formatFieldLabel("")).toBe(""); }); }); + +describe("normalizeUnionType", () => { + test("normalizes anyOf with string and null to string type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes anyOf with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes array type with string and null to string type", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes array type with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + type: ["boolean", "null"], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes anyOf with number and null to number type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes anyOf with integer and null to integer type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("normalizes array type with number and null to number type", () => { + const schema: JsonSchemaType = { + type: ["number", "null"], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes array type with integer and null to integer type", () => { + const schema: JsonSchemaType = { + type: ["integer", "null"], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("handles anyOf with reversed order (null first)", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "null" }, { type: "string" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + }); + + test("leaves non-union schemas unchanged", () => { + const schema: JsonSchemaType = { + type: "string", + description: "Regular string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with more than two types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves array type with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + type: ["string", "number"], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("handles schemas without type or anyOf", () => { + const schema: JsonSchemaType = { + description: "Schema without type", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("preserves other properties when normalizing", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string", + minLength: 1, + maxLength: 100, + pattern: "^[a-z]+$", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string"); + expect(normalized.minLength).toBe(1); + expect(normalized.maxLength).toBe(100); + expect(normalized.pattern).toBe("^[a-z]+$"); + }); +}); + +describe("Output Schema Validation", () => { + const mockTools: Tool[] = [ + { + name: "weatherTool", + description: "Get weather information", + inputSchema: { + type: "object", + properties: { + city: { type: "string" }, + }, + }, + outputSchema: { + type: "object", + properties: { + temperature: { type: "number" }, + humidity: { type: "number" }, + }, + required: ["temperature", "humidity"], + }, + }, + { + name: "noOutputSchema", + description: "Tool without output schema", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "complexOutputSchema", + description: "Tool with complex output schema", + inputSchema: { + type: "object", + properties: {}, + }, + outputSchema: { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + tags: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["user"], + }, + }, + ]; + + beforeEach(() => { + // Clear cache before each test + cacheToolOutputSchemas([]); + }); + + describe("cacheToolOutputSchemas", () => { + test("caches validators for tools with output schemas", () => { + cacheToolOutputSchemas(mockTools); + + expect(hasOutputSchema("weatherTool")).toBe(true); + expect(hasOutputSchema("complexOutputSchema")).toBe(true); + expect(hasOutputSchema("noOutputSchema")).toBe(false); + }); + + test("clears existing cache when called", () => { + cacheToolOutputSchemas(mockTools); + expect(hasOutputSchema("weatherTool")).toBe(true); + + cacheToolOutputSchemas([]); + expect(hasOutputSchema("weatherTool")).toBe(false); + }); + + test("handles invalid output schemas gracefully", () => { + const toolsWithInvalidSchema: Tool[] = [ + { + name: "invalidSchemaTool", + description: "Tool with invalid schema", + inputSchema: { type: "object", properties: {} }, + outputSchema: { + // @ts-expect-error Testing with invalid type + type: "invalid-type", + }, + }, + ]; + + // Should not throw + expect(() => + cacheToolOutputSchemas(toolsWithInvalidSchema), + ).not.toThrow(); + expect(hasOutputSchema("invalidSchemaTool")).toBe(false); + }); + }); + + describe("validateToolOutput", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("validates correct structured content", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + humidity: 60, + }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("rejects invalid structured content", () => { + const result = validateToolOutput("weatherTool", { + temperature: "25.5", // Should be number + humidity: 60, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("should be number"); + }); + + test("rejects missing required fields", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + // Missing humidity + }); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("required"); + }); + + test("validates complex nested structures", () => { + const validResult = validateToolOutput("complexOutputSchema", { + user: { + name: "John", + age: 30, + }, + tags: ["tag1", "tag2"], + }); + + expect(validResult.isValid).toBe(true); + + const invalidResult = validateToolOutput("complexOutputSchema", { + user: { + // Missing required 'name' + age: 30, + }, + }); + + expect(invalidResult.isValid).toBe(false); + }); + + test("returns valid for tools without validators", () => { + const result = validateToolOutput("nonExistentTool", { any: "data" }); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("validates additional properties restriction", () => { + const result = validateToolOutput("weatherTool", { + temperature: 25.5, + humidity: 60, + extraField: "should not be here", + }); + + // This depends on whether additionalProperties is set to false in the schema + // If it is, this should fail + expect(result.isValid).toBe(true); // By default, additional properties are allowed + }); + }); + + describe("getToolOutputValidator", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("returns validator for cached tool", () => { + const validator = getToolOutputValidator("weatherTool"); + expect(validator).toBeDefined(); + expect(typeof validator).toBe("function"); + }); + + test("returns undefined for tool without output schema", () => { + const validator = getToolOutputValidator("noOutputSchema"); + expect(validator).toBeUndefined(); + }); + + test("returns undefined for non-existent tool", () => { + const validator = getToolOutputValidator("nonExistentTool"); + expect(validator).toBeUndefined(); + }); + }); + + describe("hasOutputSchema", () => { + beforeEach(() => { + cacheToolOutputSchemas(mockTools); + }); + + test("returns true for tools with output schemas", () => { + expect(hasOutputSchema("weatherTool")).toBe(true); + expect(hasOutputSchema("complexOutputSchema")).toBe(true); + }); + + test("returns false for tools without output schemas", () => { + expect(hasOutputSchema("noOutputSchema")).toBe(false); + }); + + test("returns false for non-existent tools", () => { + expect(hasOutputSchema("nonExistentTool")).toBe(false); + }); + }); +}); diff --git a/client/src/utils/configUtils.ts b/client/src/utils/configUtils.ts index ae404d65..418f0007 100644 --- a/client/src/utils/configUtils.ts +++ b/client/src/utils/configUtils.ts @@ -4,12 +4,27 @@ 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; + let proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string; if (proxyFullAddress) { + proxyFullAddress = proxyFullAddress.replace(/\/+$/, ""); 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 +43,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 +118,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..60f5b1d6 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -7,20 +7,46 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +export type JsonSchemaConst = { + const: JsonValue; + title?: string; + description?: string; +}; + export type JsonSchemaType = { - type: + type?: | "string" | "number" | "integer" | "boolean" | "array" | "object" - | "null"; + | "null" + | ( + | "string" + | "number" + | "integer" + | "boolean" + | "array" + | "object" + | "null" + )[]; + title?: string; description?: string; - required?: boolean; + required?: string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + enum?: string[]; + const?: JsonValue; + oneOf?: (JsonSchemaType | JsonSchemaConst)[]; + anyOf?: (JsonSchemaType | JsonSchemaConst)[]; }; export type JsonObject = { [key: string]: JsonValue }; diff --git a/client/src/utils/oauthUtils.ts b/client/src/utils/oauthUtils.ts index c971271e..83de080b 100644 --- a/client/src/utils/oauthUtils.ts +++ b/client/src/utils/oauthUtils.ts @@ -48,6 +48,20 @@ export const parseOAuthCallbackParams = (location: string): CallbackParams => { }; }; +/** + * Generate a random state for the OAuth 2.0 flow. + * + * @returns A random state for the OAuth 2.0 flow. + */ +export const generateOAuthState = () => { + // Generate a random state + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +}; + export const generateOAuthErrorDescription = ( params: Extract, ): string => { diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 520b7908..7a175142 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -1,48 +1,239 @@ import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const ajv = new Ajv(); + +// Cache for compiled validators +const toolOutputValidators = new Map(); + +/** + * Compiles and caches output schema validators for a list of tools + * Following the same pattern as SDK's Client.cacheToolOutputSchemas + * @param tools Array of tools that may have output schemas + */ +export function cacheToolOutputSchemas(tools: Tool[]): void { + toolOutputValidators.clear(); + for (const tool of tools) { + if (tool.outputSchema) { + try { + const validator = ajv.compile(tool.outputSchema); + toolOutputValidators.set(tool.name, validator); + } catch (error) { + console.warn( + `Failed to compile output schema for tool ${tool.name}:`, + error, + ); + } + } + } +} + +/** + * Gets the cached output schema validator for a tool + * Following the same pattern as SDK's Client.getToolOutputValidator + * @param toolName Name of the tool + * @returns The compiled validator function, or undefined if not found + */ +export function getToolOutputValidator( + toolName: string, +): ValidateFunction | undefined { + return toolOutputValidators.get(toolName); +} + +/** + * Validates structured content against a tool's output schema + * Returns validation result with detailed error messages + * @param toolName Name of the tool + * @param structuredContent The structured content to validate + * @returns An object with isValid boolean and optional error message + */ +export function validateToolOutput( + toolName: string, + structuredContent: unknown, +): { isValid: boolean; error?: string } { + const validator = getToolOutputValidator(toolName); + if (!validator) { + return { isValid: true }; // No validator means no schema to validate against + } + + const isValid = validator(structuredContent); + if (!isValid) { + return { + isValid: false, + error: ajv.errorsText(validator.errors), + }; + } + + return { isValid: true }; +} + +/** + * Checks if a tool has an output schema + * @param toolName Name of the tool + * @returns true if the tool has an output schema + */ +export function hasOutputSchema(toolName: string): boolean { + return toolOutputValidators.has(toolName); +} /** * 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; +} + +/** + * Normalizes union types (like string|null from FastMCP) to simple types for form rendering + * @param schema The JSON schema to normalize + * @returns A normalized schema or the original schema + */ +export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { + // Handle anyOf with exactly string and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "string", anyOf: undefined }; + } + + // Handle anyOf with exactly boolean and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "boolean", anyOf: undefined }; + } + + // Handle anyOf with exactly number and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "number", anyOf: undefined }; + } + + // Handle anyOf with exactly integer and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "integer", anyOf: undefined }; + } + + // Handle array type with exactly string and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("string") && + schema.type.includes("null") + ) { + return { ...schema, type: "string" }; + } + + // Handle array type with exactly boolean and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("boolean") && + schema.type.includes("null") + ) { + return { ...schema, type: "boolean" }; + } + + // Handle array type with exactly number and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("number") && + schema.type.includes("null") + ) { + return { ...schema, type: "number" }; + } + + // Handle array type with exactly integer and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("integer") && + schema.type.includes("null") + ) { + return { ...schema, type: "integer" }; + } + + return schema; +} + /** * 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 92e89f74..3a99a46e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.16.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.16.4", "license": "MIT", "workspaces": [ "client", @@ -14,36 +14,40 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.12.0", - "@modelcontextprotocol/inspector-client": "^0.12.0", - "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.2", - "concurrently": "^9.0.1", - "open": "^10.1.0", - "shell-quote": "^1.8.2", + "@modelcontextprotocol/inspector-cli": "^0.16.4", + "@modelcontextprotocol/inspector-client": "^0.16.4", + "@modelcontextprotocol/inspector-server": "^0.16.4", + "@modelcontextprotocol/sdk": "^1.17.2", + "concurrently": "^9.2.0", + "open": "^10.2.0", + "shell-quote": "^1.8.3", "spawn-rx": "^5.1.2", "ts-node": "^10.9.2", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "bin": { "mcp-inspector": "cli/build/cli.js" }, "devDependencies": { + "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", - "@types/node": "^22.7.5", + "@types/node": "^22.17.0", "@types/shell-quote": "^1.7.5", "jest-fixed-jsdom": "^0.0.9", - "prettier": "3.3.3", + "prettier": "^3.6.2", "rimraf": "^6.0.1", "typescript": "^5.4.2" + }, + "engines": { + "node": ">=22.7.5" } }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.12.0", + "version": "0.16.4", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.17.2", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -63,10 +67,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.12.0", + "version": "0.16.4", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.17.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -77,10 +81,11 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", - "lucide-react": "^0.447.0", + "lucide-react": "^0.523.0", "pkce-challenge": "^4.1.0", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -89,7 +94,7 @@ "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "bin": { "mcp-inspector-client": "bin/start.js" @@ -99,12 +104,12 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", - "@types/node": "^22.7.5", + "@types/node": "^22.17.0", "@types/prismjs": "^1.26.5", - "@types/react": "^18.3.10", + "@types/react": "^18.3.23", "@types/react-dom": "^18.3.0", "@types/serve-handler": "^6.1.4", - "@vitejs/plugin-react": "^4.3.2", + "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", "co": "^4.6.0", "eslint": "^9.11.1", @@ -113,12 +118,12 @@ "globals": "^15.9.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "postcss": "^8.4.47", + "postcss": "^8.5.6", "tailwindcss": "^3.4.13", - "ts-jest": "^29.2.6", + "ts-jest": "^29.4.0", "typescript": "^5.5.3", - "typescript-eslint": "^8.7.0", - "vite": "^6.3.0" + "typescript-eslint": "^8.38.0", + "vite": "^6.3.5" } }, "client/node_modules/pkce-challenge": { @@ -179,9 +184,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.1.tgz", - "integrity": "sha512-Q+E+rd/yBzNQhXkG+zQnF58e4zoZfBedaxwzPmicKsiK3nt8iJYrSrDbjwFFDGC4f+rPafqRaPH6TsDoSvMf7A==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "dev": true, "license": "MIT", "engines": { @@ -189,22 +194,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", - "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helpers": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -220,16 +225,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -237,13 +242,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.1.tgz", - "integrity": "sha512-2YaDd/Rd9E598B5+WIc8wJPmWETiiJXFYVE60oX8FDohv7rAUU3CQj+A1MgeEmcsk2+dQuEjIe/GDvig0SqL4g==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.1", + "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -253,6 +258,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", @@ -268,15 +283,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", - "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -326,27 +341,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", - "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", - "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -637,14 +652,14 @@ } }, "node_modules/@babel/template": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", - "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.1", + "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" }, "engines": { @@ -652,38 +667,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1191,9 +1196,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1206,9 +1211,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1216,9 +1221,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1266,13 +1271,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.25.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz", - "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==", + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -1286,13 +1294,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -1944,17 +1952,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1966,15 +1970,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1982,9 +1977,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2004,14 +1999,17 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz", - "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==", + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.2.tgz", + "integrity": "sha512-EFLRNXR/ixpXQWu6/3Cu30ndDFIFNaqUXcTqsGebujeMan9FzhAaFFswLRiFj61rgygDRr8WO1N+UijjgRxX9g==", + "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -2068,6 +2066,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "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", @@ -2081,12 +2095,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": "*", @@ -2104,16 +2118,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" @@ -2134,15 +2148,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": "*", @@ -2190,22 +2204,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" @@ -2241,14 +2255,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" }, @@ -2283,13 +2297,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": { @@ -2335,12 +2349,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": "*", @@ -2358,23 +2372,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" @@ -2395,16 +2409,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", @@ -2427,12 +2441,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": { @@ -2475,12 +2489,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": "*", @@ -2498,18 +2512,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" }, @@ -2529,30 +2543,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" }, @@ -2572,9 +2586,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" @@ -2590,9 +2604,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", @@ -2600,8 +2614,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": { @@ -2620,23 +2634,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": "*", @@ -2654,23 +2668,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": "*", @@ -2824,12 +2838,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": "*", @@ -2852,6 +2866,13 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.40.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", @@ -3359,9 +3380,9 @@ } }, "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "dev": true, "license": "MIT", "dependencies": { @@ -3376,9 +3397,9 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3518,9 +3539,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", - "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3555,9 +3576,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", - "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3657,21 +3678,21 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", - "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/type-utils": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3681,22 +3702,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", - "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "engines": { @@ -3711,15 +3742,37 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", - "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1" + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3729,17 +3782,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", - "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/utils": "8.31.1", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3754,9 +3825,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", - "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "dev": true, "license": "MIT", "engines": { @@ -3768,20 +3839,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", - "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3795,9 +3868,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3821,9 +3894,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -3834,16 +3907,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", - "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3858,14 +3931,14 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", - "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3876,15 +3949,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", - "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -3892,7 +3966,7 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/abab": { @@ -3917,9 +3991,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3978,7 +4052,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4292,9 +4365,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4680,9 +4753,9 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", - "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -5320,20 +5393,20 @@ } }, "node_modules/eslint": { - "version": "9.25.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz", - "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.25.1", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5344,9 +5417,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5404,9 +5477,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5421,9 +5494,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5434,15 +5507,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5652,7 +5725,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5687,7 +5759,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -5740,9 +5811,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5858,15 +5929,16 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -7701,7 +7773,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -7845,12 +7916,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": { @@ -8202,15 +8273,15 @@ } }, "node_modules/open": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", - "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "wsl-utils": "^0.1.0" }, "engines": { "node": ">=18" @@ -8407,15 +8478,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8530,10 +8592,57 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "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", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -8550,7 +8659,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8684,9 +8793,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { @@ -8782,7 +8891,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9148,9 +9256,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9496,9 +9604,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9819,9 +9927,9 @@ } }, "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -10153,21 +10261,20 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.3.2", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.2.tgz", - "integrity": "sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==", + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.1", - "type-fest": "^4.39.1", + "semver": "^7.7.2", + "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -10178,10 +10285,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -10199,13 +10307,16 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -10216,9 +10327,9 @@ } }, "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.1.tgz", - "integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -10367,15 +10478,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.1.tgz", - "integrity": "sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", + "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.31.1", - "@typescript-eslint/parser": "8.31.1", - "@typescript-eslint/utils": "8.31.1" + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10449,7 +10561,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -10546,9 +10657,9 @@ } }, "node_modules/vite": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", - "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10819,6 +10930,21 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -10914,9 +11040,9 @@ } }, "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -10933,21 +11059,21 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.12.0", + "version": "0.16.4", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.17.2", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "bin": { "mcp-inspector-server": "build/index.js" }, "devDependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.23", "@types/ws": "^8.5.12", "tsx": "^4.19.0", "typescript": "^5.6.2" diff --git a/package.json b/package.json index dcbd3bf1..c2a78331 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.12.0", + "version": "0.16.4", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,37 +27,48 @@ "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", + "dev:sdk": "npm run link:sdk && concurrently \"npm run dev\" \"cd sdk && npm run build:esm:w\"", + "link:sdk": "(test -d sdk || ln -sf ${MCP_SDK:-$PWD/../typescript-sdk} sdk) && (cd sdk && npm link && (test -d node_modules || npm i)) && npm link @modelcontextprotocol/sdk", + "unlink:sdk": "(cd sdk && npm unlink -g) && rm sdk && npm unlink @modelcontextprotocol/sdk", "start": "node client/bin/start.js", "start-server": "cd server && npm run start", "start-client": "cd client && npm run preview", "test": "npm run prettier-check && cd client && npm test", "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.12.0", - "@modelcontextprotocol/inspector-client": "^0.12.0", - "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.2", - "concurrently": "^9.0.1", - "open": "^10.1.0", - "shell-quote": "^1.8.2", + "@modelcontextprotocol/inspector-cli": "^0.16.4", + "@modelcontextprotocol/inspector-client": "^0.16.4", + "@modelcontextprotocol/inspector-server": "^0.16.4", + "@modelcontextprotocol/sdk": "^1.17.2", + "concurrently": "^9.2.0", + "open": "^10.2.0", + "shell-quote": "^1.8.3", "spawn-rx": "^5.1.2", "ts-node": "^10.9.2", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "devDependencies": { + "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", - "@types/node": "^22.7.5", + "@types/node": "^22.17.0", "@types/shell-quote": "^1.7.5", "jest-fixed-jsdom": "^0.0.9", - "prettier": "3.3.3", + "prettier": "^3.6.2", "rimraf": "^6.0.1", "typescript": "^5.4.2" + }, + "engines": { + "node": ">=22.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 72723d3f..4047e2d8 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.12.0", + "version": "0.16.4", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -20,17 +20,17 @@ "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" }, "devDependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.23", "@types/ws": "^8.5.12", "tsx": "^4.19.0", "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.17.2", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", - "zod": "^3.23.8" + "zod": "^3.25.76" } } diff --git a/server/src/index.ts b/server/src/index.ts index c113d1ea..0a0f7bcc 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", @@ -38,9 +39,50 @@ const { values } = parseArgs({ options: { env: { type: "string", default: "" }, args: { type: "string", default: "" }, + command: { type: "string", default: "" }, + transport: { type: "string", default: "" }, + "server-url": { type: "string", default: "" }, }, }); +// 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 +90,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_AUTH_TOKEN || randomBytes(32).toString("hex"); +const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; + +// Origin validation middleware to prevent DNS rebinding attacks +const originValidationMiddleware = ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + const origin = req.headers.origin; + + // Default origins based on CLIENT_PORT or use environment variable + const clientPort = process.env.CLIENT_PORT || "6274"; + const defaultOrigin = `http://localhost:${clientPort}`; + const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [ + defaultOrigin, + ]; + + if (origin && !allowedOrigins.includes(origin)) { + console.error(`Invalid origin: ${origin}`); + res.status(403).json({ + error: "Forbidden - invalid origin", + message: + "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.", + }); + return; + } + next(); +}; + +const authMiddleware = ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { + if (authDisabled) { + return next(); + } + + const sendUnauthorized = () => { + res.status(401).json({ + error: "Unauthorized", + message: + "Authentication required. Use the session token shown in the console when starting the server.", + }); + }; + + const authHeader = req.headers["x-mcp-proxy-auth"]; + const authHeaderValue = Array.isArray(authHeader) + ? authHeader[0] + : authHeader; + + if (!authHeaderValue || !authHeaderValue.startsWith("Bearer ")) { + sendUnauthorized(); + return; + } + + const providedToken = authHeaderValue.substring(7); // Remove 'Bearer ' prefix + const expectedToken = sessionToken; + + // Convert to buffers for timing-safe comparison + const providedBuffer = Buffer.from(providedToken); + const expectedBuffer = Buffer.from(expectedToken); + + // Check length first to prevent timing attacks + if (providedBuffer.length !== expectedBuffer.length) { + sendUnauthorized(); + return; + } + + // Perform timing-safe comparison + if (!timingSafeEqual(providedBuffer, expectedBuffer)) { + sendUnauthorized(); + return; + } + + next(); +}; 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 +188,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 +198,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 +217,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 +230,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 +237,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 +391,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,12 +519,14 @@ app.get("/health", (req, res) => { }); }); -app.get("/config", (req, res) => { +app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { try { res.json({ defaultEnvironment, - defaultCommand: values.env, + defaultCommand: values.command, defaultArgs: values.args, + defaultTransport: values.transport, + defaultServerUrl: values["server-url"], }); } catch (error) { console.error("Error in /config route:", error); @@ -359,11 +534,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 b93c0cdf..664f1711 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -1,11 +1,18 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; function onClientError(error: Error) { console.error("Error from inspector client:", error); } function onServerError(error: Error) { - console.error("Error from MCP server:", error); + if (error?.cause && JSON.stringify(error.cause).includes("ECONNREFUSED")) { + console.error("Connection refused. Is the MCP server running?"); + } else if (error.message && error.message.includes("404")) { + console.error("Error accessing endpoint (HTTP 404)"); + } else { + console.error("Error from MCP server:", error); + } } export default function mcpProxy({ @@ -18,11 +25,36 @@ export default function mcpProxy({ let transportToClientClosed = false; let transportToServerClosed = false; + let reportedServerSession = false; + transportToClient.onmessage = (message) => { - transportToServer.send(message).catch(onServerError); + transportToServer.send(message).catch((error) => { + // Send error response back to client if it was a request (has id) and connection is still open + if (isJSONRPCRequest(message) && !transportToClientClosed) { + const errorResponse = { + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32001, + message: error.message, + data: error, + }, + }; + transportToClient.send(errorResponse).catch(onClientError); + } + }); }; transportToServer.onmessage = (message) => { + if (!reportedServerSession) { + if (transportToServer.sessionId) { + // Can only report for StreamableHttp + console.error( + "Proxy <-> Server sessionId: " + transportToServer.sessionId, + ); + } + reportedServerSession = true; + } transportToClient.send(message).catch(onClientError); };