diff --git a/.dockerignore b/.dockerignore index 39d62ea77..73499c81e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,9 +7,9 @@ node_modules npm-debug.log # Build artifacts -web/dist -cli/build -tui/build +clients/web/dist +clients/cli/build +clients/tui/build # Environment variables .env diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index ed2b2c1b5..ea61b83ee 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -3,17 +3,17 @@ name: CLI Tests on: push: paths: - - "cli/**" + - "clients/cli/**" pull_request: paths: - - "cli/**" + - "clients/cli/**" jobs: test: runs-on: ubuntu-latest defaults: run: - working-directory: ./cli + working-directory: ./clients/cli steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index c9849f992..dfe13fa77 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -56,9 +56,9 @@ jobs: with: name: playwright-report path: | - web/playwright-report/ - web/test-results/ - web/results.json + clients/web/playwright-report/ + clients/web/test-results/ + clients/web/results.json retention-days: 2 - name: Publish Playwright Test Summary @@ -66,7 +66,7 @@ jobs: if: steps.playwright-tests.conclusion != 'skipped' with: create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - report-file: web/results.json + report-file: clients/web/results.json comment-title: "šŸŽ­ Playwright E2E Test Results" job-summary: true icon-style: "emojis" diff --git a/.gitignore b/.gitignore index e1e4848ba..7587da6fc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,22 +3,25 @@ .idea node_modules/ *-workspace/ -web/tsconfig.app.tsbuildinfo -web/tsconfig.node.tsbuildinfo -web/tsconfig.jest.tsbuildinfo +clients/web/tsconfig.app.tsbuildinfo +clients/web/tsconfig.node.tsbuildinfo +clients/web/tsconfig.jest.tsbuildinfo core/build -cli/build -tui/build +clients/cli/build +clients/tui/build +clients/launcher/build +clients/web/build +clients/web/dist test-servers/build test-output tool-test-output metadata-test-output # symlinked by `npm run link:sdk`: sdk -web/playwright-report/ -web/results.json -web/test-results/ -web/e2e/test-results/ +clients/web/playwright-report/ +clients/web/results.json +clients/web/test-results/ +clients/web/e2e/test-results/ # Only ignore mcp.json at repo root (configs in configs/ are committed) /mcp.json .claude/settings.local.json diff --git a/.prettierignore b/.prettierignore index 6f8827fb8..a9a57c95b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,10 @@ packages core/build -web/dist -cli/build -tui/build +clients/web/dist +clients/web/build +clients/cli/build +clients/tui/build +clients/launcher/build test-servers/build CODE_OF_CONDUCT.md SECURITY.md diff --git a/AGENTS.md b/AGENTS.md index 968a5a838..7155240ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - Build web: `npm run build-web` - Development mode: `npm run dev` (use `npm run dev:windows` on Windows) - Format code: `npm run prettier-fix` -- Web lint: `cd web && npm run lint` +- Web lint: `cd clients/web && npm run lint` ## Code Style Guidelines @@ -25,12 +25,23 @@ - Use Tailwind CSS for styling in the web app - Keep components small and focused on a single responsibility +## Tool Input Parameter Handling + +When implementing or modifying tool input parameter handling in the Inspector: + +- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value +- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects +- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages +- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema + +These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers. + ## Project Organization The project is organized as a monorepo with workspaces: -- `web/`: Web application (Vite, TypeScript, Tailwind) +- `clients/web/`: Web application (Vite, TypeScript, Tailwind) - `core/`: Core shared code used by web, CLI, and TUI -- `cli/`: Command-line interface for testing and invoking MCP server methods directly -- `tui/`: Terminal user interface +- `clients/cli/`: Command-line interface for testing and invoking MCP server methods directly +- `clients/tui/`: Terminal user interface - `test-servers/`: Composable MCP test servers, fixtures, and harness diff --git a/Dockerfile b/Dockerfile index 2b4adfbc6..4dd3e81fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,11 @@ WORKDIR /app # Copy package files for installation COPY package*.json ./ COPY .npmrc ./ -COPY web/package*.json ./web/ +COPY clients/web/package*.json ./clients/web/ COPY core/package*.json ./core/ -COPY cli/package*.json ./cli/ -COPY tui/package*.json ./tui/ +COPY clients/cli/package*.json ./clients/cli/ +COPY clients/tui/package*.json ./clients/tui/ +COPY clients/launcher/package*.json ./clients/launcher/ # Install dependencies RUN npm ci --ignore-scripts @@ -29,22 +30,24 @@ WORKDIR /app # Copy package files for production COPY package*.json ./ COPY .npmrc ./ -COPY web/package*.json ./web/ +COPY clients/web/package*.json ./clients/web/ COPY core/package*.json ./core/ -COPY cli/package*.json ./cli/ -COPY tui/package*.json ./tui/ +COPY clients/cli/package*.json ./clients/cli/ +COPY clients/tui/package*.json ./clients/tui/ +COPY clients/launcher/package*.json ./clients/launcher/ # Install only production dependencies RUN npm ci --omit=dev --ignore-scripts # Copy built files from builder stage -COPY --from=builder /app/web/dist ./web/dist -COPY --from=builder /app/web/bin ./web/bin -COPY --from=builder /app/cli/build ./cli/build +COPY --from=builder /app/clients/web/dist ./clients/web/dist +COPY --from=builder /app/clients/web/build ./clients/web/build +COPY --from=builder /app/clients/cli/build ./clients/cli/build +COPY --from=builder /app/clients/launcher/build ./clients/launcher/build # Set default port ENV PORT=6274 EXPOSE ${PORT} # Run web app -CMD ["node", "web/bin/start.js"] +CMD ["node", "clients/launcher/build/index.js", "--web"] diff --git a/README.md b/README.md index f68af0d92..56aebe2a5 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,21 @@ # MCP Inspector -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) +The MCP inspector is a developer tool for testing and debugging [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers. It is provided as a web app, a terminal user interface, and a CLI, all supported by a shared core package that is also available to application developers for use in their own apps. ## 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. +The MCP Inspector provides multiple client interfaces built around a shared core protocol layer. For a deep dive into the underlying architecture, see our [Shared Code Architecture documentation](./docs/shared-code-architecture.md). -## Running the Inspector +![Shared Code Architecture](./docs/shared-code-architecture.svg) -### Requirements +The repository consists of the following main packages: -- Node.js: ^22.7.5 +- **[Web Client](./clients/web/README.md)**: A rich, interactive React-based browser UI for exploring, testing, and debugging MCP servers. Provides forms for tool execution, resource exploration, and prompt sampling. +- **[CLI Client](./clients/cli/README.md)**: A command-line interface for programmatic interaction with MCP servers. Ideal for scripting, automation, and creating feedback loops with AI coding assistants. +- **[TUI Client](./clients/tui/README.md)**: A terminal user interface that brings the interactive exploration capabilities of the web client directly to your terminal. +- **[Core](./core/README.md)**: The shared library providing `InspectorClient` and state managers, ensuring consistent protocol behavior across all interfaces. -### Quick Start (UI mode) +## Quick Start (Web UI) To get up and running right away with the UI, just execute the following: @@ -29,452 +25,51 @@ npx @modelcontextprotocol/inspector The server will start up and the UI will be accessible at `http://localhost:6274`. -### Docker Container - -You can also start it in a Docker container with the following command: - -```bash -docker run --rm \ - -p 127.0.0.1:6274:6274 \ - -p 127.0.0.1:6277:6277 \ - -e HOST=0.0.0.0 \ - -e MCP_AUTO_OPEN_ENABLED=false \ - ghcr.io/modelcontextprotocol/inspector:latest -``` - -### 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`: - -```bash -npx @modelcontextprotocol/inspector node build/index.js -``` - -You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: - -```bash -# Pass arguments only -npx @modelcontextprotocol/inspector node build/index.js arg1 arg2 - -# Pass environment variables only -npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js - -# Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js arg1 arg2 - -# Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag -``` - -The inspector runs both an MCP Inspector (MCPI) client UI (default port 6274) and an MCP Proxy (MCPP) server (default port 6277). Open the MCPI client UI in your browser to use the inspector. (These ports are derived from the T9 dialpad mapping of MCPI and MCPP respectively, as a mnemonic). You can customize the ports if needed: - -```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js -``` - -**Environment variables:** The client UI port is set with `CLIENT_PORT`. The sandbox (MCP apps) port prefers `MCP_SANDBOX_PORT`; `SERVER_PORT` is accepted for backward compatibility. If neither is set, a dynamic port is used. - -For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). - -### Servers File Export - -The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`. - -- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name. - - **STDIO transport example:** - - ```json - { - "command": "node", - "args": ["build/index.js", "--debug"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - ``` - - **SSE transport example:** - - ```json - { - "type": "sse", - "url": "http://localhost:3000/events", - "note": "For SSE connections, add this URL directly in Client" - } - ``` - - **Streamable HTTP transport example:** - - ```json - { - "type": "streamable-http", - "url": "http://localhost:3000/mcp", - "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" - } - ``` - -- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`. - - **STDIO transport example:** +> **Note**: For detailed usage instructions, configuration files (`mcp.json`), Docker deployment, and important security considerations, please see the [Web Client README](./clients/web/README.md). - ```json - { - "mcpServers": { - "default-server": { - "command": "node", - "args": ["build/index.js", "--debug"], - "env": { - "API_KEY": "your-api-key", - "DEBUG": "true" - } - } - } - } - ``` +## CLI Quick Start - **SSE transport example:** - - ```json - { - "mcpServers": { - "default-server": { - "type": "sse", - "url": "http://localhost:3000/events", - "note": "For SSE connections, add this URL directly in Client" - } - } - } - ``` - - **Streamable HTTP transport example:** - - ```json - { - "mcpServers": { - "default-server": { - "type": "streamable-http", - "url": "http://localhost:3000/mcp", - "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" - } - } - } - ``` - -These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations. - -For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients. - -You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file. - -### Authentication - -The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar. - -### Security Considerations - -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_INSPECTOR_API_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 -``` - -This token must be included as a Bearer token in the Authorization header for all requests to the server. The inspector will automatically open your browser with the token pre-filled in the URL. - -**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 -``` - ---- - -**🚨 WARNING 🚨** - -Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks. - -Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) - ---- - -You can also set the token via the `MCP_INSPECTOR_API_TOKEN` environment variable when starting the server (`MCP_PROXY_AUTH_TOKEN` is accepted for backward compatibility): +To use the CLI, pass the command that starts your MCP server, then the method you want (e.g. list tools): ```bash -MCP_INSPECTOR_API_TOKEN=$(openssl rand -hex 32) npm start +npx @modelcontextprotocol/inspector --cli --method tools/list ``` -#### Local-only Binding +Replace `` with how you run your server (e.g. `node build/index.js` or `npx @modelcontextprotocol/server-everything`). -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: +> **Note**: For full CLI capabilities, argument formatting, and scripting examples, please see the [CLI README](./clients/cli/README.md). -```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. +## TUI Quick Start -#### 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): +To launch the interactive terminal UI, pass the command that starts your MCP server: ```bash -ALLOWED_ORIGINS=http://localhost:6274,http://localhost:8000 npm start +npx @modelcontextprotocol/inspector --tui ``` -### Configuration - -The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: +Replace `` with how you run your server (e.g. `node build/index.js`). -| Setting | Description | Default | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `MCP_SERVER_REQUEST_TIMEOUT` | Client-side timeout (ms) - Inspector will cancel the request if no response is received within this time. Note: servers may have their own timeouts | 300000 | -| `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 | - -**Note on Timeouts:** The timeout settings above control when the Inspector (as an MCP client) will cancel requests. These are independent of any server-side timeouts. For example, if a server tool has a 10-minute timeout but the Inspector's timeout is set to 30 seconds, the Inspector will cancel the request after 30 seconds. Conversely, if the Inspector's timeout is 10 minutes but the server times out after 30 seconds, you'll receive the server's timeout error. For tools that require user interaction (like elicitation) or long-running operations, ensure the Inspector's timeout is set appropriately. - -These settings can be adjusted in real-time through the UI and will persist across sessions. - -The inspector also supports configuration files to store settings for different MCP servers. This is useful when working with multiple servers or complex configurations: - -```bash -npx @modelcontextprotocol/inspector --config path/to/config.json --server everything -``` - -Example server configuration file: - -```json -{ - "mcpServers": { - "everything": { - "command": "npx", - "args": ["@modelcontextprotocol/server-everything"], - "env": { - "hello": "Hello MCP!" - } - }, - "my-server": { - "command": "node", - "args": ["build/index.js", "arg1", "arg2"], - "env": { - "key": "value", - "key2": "value2" - } - } - } -} -``` - -#### 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: - -``` -http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse -http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp -http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2 -``` - -You can also set initial config settings via query params, for example: - -``` -http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=60000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577 -``` - -Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence. - -### From this repository - -If you're working on the inspector itself: - -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:** -> On Windows, use the following command instead: -> -> ```bash -> npm run dev:windows -> ``` - -Production mode: - -```bash -npm run build -npm start -``` - -### CLI Mode - -CLI mode enables programmatic interaction with MCP servers from the command line, ideal for scripting, automation, and integration with coding assistants. This creates an efficient feedback loop for MCP server development. - -```bash -npx @modelcontextprotocol/inspector --cli node build/index.js -``` - -The CLI mode supports most operations across tools, resources, and prompts. A few examples: - -```bash -# Basic usage -npx @modelcontextprotocol/inspector --cli node build/index.js - -# With config file -npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver - -# List available tools -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list - -# Call a specific tool -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2 - -# Call a tool with JSON arguments -npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}' - -# List available resources -npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list - -# List available prompts -npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list - -# 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 - -# Connect to a remote MCP server (with custom headers) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key" - -# 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 - -# List resources from a remote server -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method resources/list -``` +> **Note**: For more information about terminal navigation and features, please see the [TUI README](./clients/tui/README.md). -### UI Mode vs CLI Mode: When to Use Each +## UI vs CLI -| Use Case | UI Mode | CLI Mode | -| ------------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Server Development** | Visual interface for interactive testing and debugging during development | Scriptable commands for quick testing and continuous integration; creates feedback loops with AI coding assistants like Cursor for rapid development | -| **Resource Exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting | -| **Tool Testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting | -| **Prompt Engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output | -| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools | -| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants | -| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints | +| Use Case | Web UI / Terminal UI | CLI | +| ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| **Server development** | Visual interface for interactive testing and debugging | Scriptable commands for quick testing and CI; feedback loops with AI coding assistants like Cursor | +| **Resource exploration** | Interactive browser with hierarchical navigation and JSON visualization | Programmatic listing and reading for automation and scripting | +| **Tool testing** | Form-based parameter input with real-time response visualization | Command-line tool execution with JSON output for scripting | +| **Prompt engineering** | Interactive sampling with streaming responses and visual comparison | Batch processing of prompts with machine-readable output | +| **Debugging** | Request history, visualized errors, and real-time notifications | Direct JSON output for log analysis and integration with other tools | +| **Automation** | N/A | Ideal for CI/CD pipelines, batch processing, and integration with coding assistants | +| **Learning MCP** | Rich visual interface helps new users understand server capabilities | Simplified commands for focused learning of specific endpoints | -## Tool Input Validation Guidelines +## Usage Documentation -When implementing or modifying tool input parameter handling in the Inspector: +To specify which MCP server(s) to connect to (config file, `-e`, `--config`, `--server`, etc.), see [MCP server configuration](docs/mcp-server-configuration.md). For more on using the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). -- **Omit optional fields with empty values** - When processing form inputs, omit empty strings or null values for optional parameters, UNLESS the field has an explicit default value in the schema that matches the current value -- **Preserve explicit default values** - If a field schema contains an explicit default (e.g., `default: null`), and the current value matches that default, include it in the request. This is a meaningful value the tool expects -- **Always include required fields** - Preserve required field values even when empty, allowing the MCP server to validate and return appropriate error messages -- **Defer deep validation to the server** - Implement basic field presence checking in the Inspector client, but rely on the MCP server for parameter validation according to its schema +## Contributing -These guidelines maintain clean parameter passing and proper separation of concerns between the Inspector client and MCP servers. +If you're working on the inspector itself, see our [Development Guide](./AGENTS.md) and [Contributing Guidelines](./CONTRIBUTING.md). ## License diff --git a/cli/src/cli.ts b/cli/src/cli.ts deleted file mode 100644 index a7212c406..000000000 --- a/cli/src/cli.ts +++ /dev/null @@ -1,478 +0,0 @@ -#!/usr/bin/env node - -import { Command } from "commander"; -import fs from "node:fs"; -import path from "node:path"; -import { dirname, resolve } from "path"; -import { spawnPromise } from "spawn-rx"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// This represents the parsed arguments produced by parseArgs() -// -type Args = { - command: string; - args: string[]; - envArgs: Record; - cli: boolean; - dev?: boolean; - transport?: "stdio" | "sse" | "streamable-http"; - serverUrl?: string; - headers?: Record; - cwd?: string; -}; - -// This is only to provide typed access to the parsed program options -// This could just be defined locally in parseArgs() since that's the only place it is used -// -type CliOptions = { - e?: Record; - config?: string; - server?: string; - cli?: boolean; - dev?: boolean; - transport?: string; - serverUrl?: string; - header?: Record; - cwd?: string; -}; - -type ServerConfig = - | { - type: "stdio"; - command: string; - args?: string[]; - env?: Record; - cwd?: string; - } - | { - type: "sse" | "streamable-http"; - url: string; - headers?: Record; - note?: string; - }; - -function handleError(error: unknown): never { - let message: string; - - if (error instanceof Error) { - message = error.message; - } else if (typeof error === "string") { - message = error; - } else { - message = "Unknown error"; - } - - console.error(message); - - process.exit(1); -} - -async function runWeb(args: Args): Promise { - // Path to the web entry point - const inspectorWebPath = resolve( - __dirname, - "../../", - "web", - "bin", - "start.js", - ); - - const abort = new AbortController(); - let cancelled: boolean = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - // Build arguments to pass to start.js - const startArgs: string[] = []; - - if (args.dev) { - startArgs.push("--dev"); - } - - // Pass environment variables - for (const [key, value] of Object.entries(args.envArgs)) { - startArgs.push("-e", `${key}=${value}`); - } - - // 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 headers if specified (for SSE/streamable-http) - if (args.headers && Object.keys(args.headers).length > 0) { - startArgs.push("--headers", JSON.stringify(args.headers)); - } - - // Pass cwd if specified (for stdio transport) - if (args.cwd) { - startArgs.push("--cwd", path.resolve(args.cwd)); - } - - // Pass command and args (using -- to separate them) - if (args.command) { - startArgs.push("--", args.command, ...args.args); - } - - try { - await spawnPromise("node", [inspectorWebPath, ...startArgs], { - signal: abort.signal, - echoOutput: true, - // pipe the stdout through here, prevents issues with buffering and - // dropping the end of console.out after 8192 chars due to node - // closing the stdout pipe before the output has finished flushing - stdio: "inherit", - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } -} - -async function runCli(args: Args): Promise { - const projectRoot = resolve(__dirname, ".."); - const cliPath = resolve(projectRoot, "build", "index.js"); - - const abort = new AbortController(); - - let cancelled = false; - - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - try { - // Build CLI arguments - const cliArgs = [cliPath]; - - // Add target URL/command first - cliArgs.push(args.command, ...args.args); - - // 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 headers if specified - if (args.headers) { - for (const [key, value] of Object.entries(args.headers)) { - cliArgs.push("--header", `${key}: ${value}`); - } - } - - // Add cwd if specified (for stdio transport) - if (args.cwd) { - cliArgs.push("--cwd", path.resolve(args.cwd)); - } - - await spawnPromise("node", cliArgs, { - env: { ...process.env, ...args.envArgs }, - signal: abort.signal, - echoOutput: true, - // pipe the stdout through here, prevents issues with buffering and - // dropping the end of console.out after 8192 chars due to node - // closing the stdout pipe before the output has finished flushing - stdio: "inherit", - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) { - throw e; - } - } -} - -async function runTui(tuiArgs: string[]): Promise { - const projectRoot = resolve(__dirname, "../.."); - const tuiPath = resolve(projectRoot, "tui", "build", "tui.js"); - - const abort = new AbortController(); - - let cancelled = false; - - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - try { - // Remove --tui flag and pass everything else directly to TUI - const filteredArgs = tuiArgs.filter((arg) => arg !== "--tui"); - - await spawnPromise("node", [tuiPath, ...filteredArgs], { - env: process.env, - signal: abort.signal, - echoOutput: true, - stdio: "inherit", - }); - } catch (e) { - if (!cancelled || process.env.DEBUG) { - throw e; - } - } -} - -function loadConfigFile(configPath: string, serverName: string): ServerConfig { - try { - const resolvedConfigPath = path.isAbsolute(configPath) - ? configPath - : path.resolve(process.cwd(), configPath); - - if (!fs.existsSync(resolvedConfigPath)) { - throw new Error(`Config file not found: ${resolvedConfigPath}`); - } - - const configContent = fs.readFileSync(resolvedConfigPath, "utf8"); - const parsedConfig = JSON.parse(configContent); - - if (!parsedConfig.mcpServers || !parsedConfig.mcpServers[serverName]) { - const availableServers = Object.keys(parsedConfig.mcpServers || {}).join( - ", ", - ); - throw new Error( - `Server '${serverName}' not found in config file. Available servers: ${availableServers}`, - ); - } - - const serverConfig = parsedConfig.mcpServers[serverName]; - if (serverConfig?.type === undefined) { - // Normalize missing type to "stdio" (backwards compatibility) - return { ...serverConfig, type: "stdio" }; - } else if (serverConfig.type === "http") { - // Normalize "http" to "streamable-http" (some clients, like Claude Code, use http instead of streamable-http) - return { ...serverConfig, type: "streamable-http" }; - } - return serverConfig; - } catch (err: unknown) { - if (err instanceof SyntaxError) { - throw new Error(`Invalid JSON in config file: ${err.message}`); - } - - throw err; - } -} - -function parseKeyValuePair( - value: string, - previous: Record = {}, -): Record { - const parts = value.split("="); - const key = parts[0]; - const val = parts.slice(1).join("="); - - if (val === undefined || val === "") { - throw new Error( - `Invalid parameter format: ${value}. Use key=value format.`, - ); - } - - return { ...previous, [key as string]: val }; -} - -function parseHeaderPair( - value: string, - previous: Record = {}, -): Record { - const colonIndex = value.indexOf(":"); - - if (colonIndex === -1) { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - const key = value.slice(0, colonIndex).trim(); - const val = value.slice(colonIndex + 1).trim(); - - if (key === "" || val === "") { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - -function parseArgs(): Args { - const program = new Command(); - - const argSeparatorIndex = process.argv.indexOf("--"); - let preArgs = process.argv; - let postArgs: string[] = []; - - if (argSeparatorIndex !== -1) { - preArgs = process.argv.slice(0, argSeparatorIndex); - postArgs = process.argv.slice(argSeparatorIndex + 1); - } - - program - .name("inspector-bin") - .allowExcessArguments() - .allowUnknownOption() - .option( - "-e ", - "environment variables in KEY=VALUE format", - parseKeyValuePair, - {}, - ) - .option("--config ", "config file path") - .option("--server ", "server name from config file") - .option("--cli", "enable CLI mode") - .option("--web", "launch web app (default)") - .option("--dev", "run web in dev mode (Vite)") - .option("--tui", "enable TUI mode") - .option("--transport ", "transport type (stdio, sse, http)") - .option("--server-url ", "server URL for SSE/HTTP transport") - .option("--cwd ", "working directory for stdio server process") - .option( - "--header ", - 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', - parseHeaderPair, - {}, - ); - - // Parse only the arguments before -- - program.parse(preArgs); - - const options = program.opts() as CliOptions; - const remainingArgs = program.args; - - // Add back any arguments that came after -- - const finalArgs = [...remainingArgs, ...postArgs]; - - // If server specified but no config, default to mcp.json in cwd if it exists - if (!options.config && options.server) { - const defaultConfigPath = path.join(process.cwd(), "mcp.json"); - if (fs.existsSync(defaultConfigPath)) { - options.config = "mcp.json"; - } else { - throw new Error("--server requires --config to be specified"); - } - } - - // If config is provided without server, try to auto-select - 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 - // from the command line and the file together, or we will miss the method options (--method, - // etc.) - if (options.config && options.server) { - const config = loadConfigFile(options.config, options.server); - if (config.type === "stdio") { - const cwd = options.cwd ?? config.cwd ?? path.resolve(process.cwd()); - return { - command: config.command, - args: [...(config.args || []), ...finalArgs], - envArgs: { ...(config.env || {}), ...(options.e || {}) }, - cli: options.cli || false, - dev: options.dev || false, - transport: "stdio", - headers: options.header, - cwd: path.resolve(cwd), - }; - } else if (config.type === "sse" || config.type === "streamable-http") { - const headers = { - ...(config.headers || {}), - ...(options.header || {}), - }; - return { - command: config.url, - args: finalArgs, - envArgs: options.e || {}, - cli: options.cli || false, - dev: options.dev || false, - transport: config.type, - serverUrl: config.url, - headers: Object.keys(headers).length > 0 ? headers : undefined, - }; - } - throw new Error(`Invalid server config: ${JSON.stringify(config)}`); - } - - // 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"; - } - - const cwd = options.cwd - ? path.resolve(options.cwd) - : path.resolve(process.cwd()); - - return { - command, - args, - envArgs: options.e || {}, - cli: options.cli || false, - dev: options.dev || false, - transport: transport as "stdio" | "sse" | "streamable-http" | undefined, - serverUrl: options.serverUrl, - headers: options.header, - cwd, - }; -} - -async function main(): Promise { - process.on("uncaughtException", (error) => { - handleError(error); - }); - - try { - // For now we just pass the raw args to TUI (we'll integrate config later) - // The main issue is that Inspector only supports a single server and the TUI supports a set - // - // Check for --tui in raw argv - if present, bypass all parsing - if (process.argv.includes("--tui")) { - await runTui(process.argv.slice(2)); - return; - } - - const args = parseArgs(); - - if (args.cli) { - await runCli(args); - } else { - await runWeb(args); - } - } catch (error) { - handleError(error); - } -} - -main(); diff --git a/clients/cli/README.md b/clients/cli/README.md new file mode 100644 index 000000000..fd3d8f87f --- /dev/null +++ b/clients/cli/README.md @@ -0,0 +1,107 @@ +# MCP Inspector CLI Client + +The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants. + +## Running the CLI + +You can run the CLI client directly via `npx`: + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js +``` + +The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output. + +### Examples + +**Basic usage** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js +``` + +**With a configuration file** + +```bash +npx @modelcontextprotocol/inspector --cli --config path/to/config.json --server myserver +``` + +**List available tools** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list +``` + +**Call a specific tool** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2 +``` + +**Call a tool with JSON arguments** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}' +``` + +**List available resources** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list +``` + +**List available prompts** + +```bash +npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list +``` + +### Remote Servers + +You can also connect to remote MCP servers using HTTP or SSE transports. + +**Connect to a remote MCP server (default is SSE)** + +```bash +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com +``` + +**Connect with Streamable HTTP transport** + +```bash +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list +``` + +**Pass custom headers** + +```bash +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key" +``` + +## Options + +### MCP server (which server to connect to) + +Options that specify the MCP server (config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--config`, `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. + +### CLI-specific (what to invoke) + +| Option | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------- | +| `--method ` | MCP method to invoke (e.g. `tools/list`, `tools/call`, `resources/list`, `prompts/list`). | +| `--tool-name ` | Tool name (for `tools/call`). | +| `--tool-arg ` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. | +| `--uri ` | Resource URI (for `resources/read`). | +| `--prompt-name ` | Prompt name (for `prompts/get`). | +| `--prompt-args ` | Prompt arguments; repeat for multiple. | +| `--log-level ` | Logging level for `logging/setLevel` (e.g. `debug`, `info`). | +| `--metadata ` | General metadata (key=value); applied to all methods. | +| `--tool-metadata ` | Tool-specific metadata for `tools/call`. | + +## Why use the CLI? + +While the Web Client provides a rich visual interface, the CLI is designed for: + +- **Automation**: Ideal for CI/CD pipelines and batch processing. +- **AI Coding Assistants**: Provides a direct, machine-readable interface (JSON) for tools like Cursor or Claude to verify changes immediately. +- **Log Analysis**: Easier integration with command-line utilities (like `jq`) to process and analyze MCP server output. diff --git a/cli/__tests__/README.md b/clients/cli/__tests__/README.md similarity index 100% rename from cli/__tests__/README.md rename to clients/cli/__tests__/README.md diff --git a/cli/__tests__/cli.test.ts b/clients/cli/__tests__/cli.test.ts similarity index 100% rename from cli/__tests__/cli.test.ts rename to clients/cli/__tests__/cli.test.ts diff --git a/cli/__tests__/headers.test.ts b/clients/cli/__tests__/headers.test.ts similarity index 100% rename from cli/__tests__/headers.test.ts rename to clients/cli/__tests__/headers.test.ts diff --git a/cli/__tests__/helpers/assertions.ts b/clients/cli/__tests__/helpers/assertions.ts similarity index 100% rename from cli/__tests__/helpers/assertions.ts rename to clients/cli/__tests__/helpers/assertions.ts diff --git a/cli/__tests__/helpers/cli-runner.ts b/clients/cli/__tests__/helpers/cli-runner.ts similarity index 97% rename from cli/__tests__/helpers/cli-runner.ts rename to clients/cli/__tests__/helpers/cli-runner.ts index 318d63b24..dbc5f5fc0 100644 --- a/cli/__tests__/helpers/cli-runner.ts +++ b/clients/cli/__tests__/helpers/cli-runner.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url"; import { dirname } from "path"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = resolve(__dirname, "../../build/cli.js"); +const CLI_PATH = resolve(__dirname, "../../build/index.js"); export interface CliResult { exitCode: number | null; diff --git a/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts similarity index 100% rename from cli/__tests__/helpers/fixtures.ts rename to clients/cli/__tests__/helpers/fixtures.ts diff --git a/cli/__tests__/metadata.test.ts b/clients/cli/__tests__/metadata.test.ts similarity index 100% rename from cli/__tests__/metadata.test.ts rename to clients/cli/__tests__/metadata.test.ts diff --git a/cli/__tests__/tools.test.ts b/clients/cli/__tests__/tools.test.ts similarity index 100% rename from cli/__tests__/tools.test.ts rename to clients/cli/__tests__/tools.test.ts diff --git a/cli/package.json b/clients/cli/package.json similarity index 81% rename from cli/package.json rename to clients/cli/package.json index 53d435273..958d65d00 100644 --- a/cli/package.json +++ b/clients/cli/package.json @@ -6,17 +6,16 @@ "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/inspector/issues", - "main": "build/cli.js", + "main": "build/index.js", "type": "module", "bin": { - "mcp-inspector-cli": "build/cli.js" + "mcp-inspector-cli": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc", - "postbuild": "node scripts/make-executable.js", "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "test": "vitest run", "test:watch": "vitest", @@ -27,15 +26,12 @@ }, "devDependencies": { "@modelcontextprotocol/inspector-test-server": "*", - "@types/express": "^5.0.0", "tsx": "^4.7.0", "vitest": "^4.0.17" }, "dependencies": { "@modelcontextprotocol/inspector-core": "*", "@modelcontextprotocol/sdk": "^1.25.2", - "commander": "^13.1.0", - "express": "^5.1.0", - "spawn-rx": "^5.1.2" + "commander": "^13.1.0" } } diff --git a/cli/src/index.ts b/clients/cli/src/cli.ts similarity index 57% rename from cli/src/index.ts rename to clients/cli/src/cli.ts index 7200e1263..51014d0e6 100644 --- a/cli/src/index.ts +++ b/clients/cli/src/cli.ts @@ -1,17 +1,10 @@ -#!/usr/bin/env node - -import * as fs from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; import { Command } from "commander"; -// CLI helper functions moved to InspectorClient methods type McpResponse = Record; import { handleError } from "./error-handler.js"; import { awaitableLog } from "./utils/awaitable-log.js"; -import type { - MCPServerConfig, - StdioServerConfig, - SseServerConfig, - StreamableHttpServerConfig, -} from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types.js"; import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import { ManagedToolsState, @@ -19,20 +12,23 @@ import { ManagedResourceTemplatesState, ManagedPromptsState, } from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; -import { createTransportNode } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import { + createTransportNode, + resolveServerConfigs, + parseKeyValuePair as parseEnvPair, + parseHeaderPair, +} from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; import type { JsonValue } from "@modelcontextprotocol/inspector-core/mcp/index.js"; import { LoggingLevelSchema, type LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; -import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"; export const validLogLevels: LoggingLevel[] = Object.values( LoggingLevelSchema.enum, ); -type Args = { - target: string[]; +type MethodArgs = { method?: string; promptName?: string; promptArgs?: Record; @@ -41,136 +37,16 @@ type Args = { toolName?: string; toolArg?: Record; toolMeta?: Record; - transport?: "sse" | "stdio" | "http"; - headers?: Record; metadata?: Record; - cwd?: string; }; -/** - * Converts CLI Args to MCPServerConfig format - * This will be used to create an InspectorClient - */ -function argsToMcpServerConfig(args: Args): MCPServerConfig { - if (args.target.length === 0) { - throw new Error( - "Target is required. Specify a URL or a command to execute.", - ); - } - - const [firstTarget, ...targetArgs] = args.target; - - if (!firstTarget) { - throw new Error("Target is required."); - } - - const isUrl = - firstTarget.startsWith("http://") || firstTarget.startsWith("https://"); - - // Validation: URLs cannot have additional arguments - if (isUrl && targetArgs.length > 0) { - throw new Error("Arguments cannot be passed to a URL-based MCP server."); - } - - // Validation: Transport/URL combinations - if (args.transport) { - if (!isUrl && args.transport !== "stdio") { - throw new Error("Only stdio transport can be used with local commands."); - } - if (isUrl && args.transport === "stdio") { - throw new Error("stdio transport cannot be used with URLs."); - } - } - - // Handle URL-based transports (SSE or streamable-http) - if (isUrl) { - const url = new URL(firstTarget); - - // Determine transport type - let transportType: "sse" | "streamable-http"; - if (args.transport) { - // Convert CLI's "http" to "streamable-http" - if (args.transport === "http") { - transportType = "streamable-http"; - } else if (args.transport === "sse") { - transportType = "sse"; - } else { - // Should not happen due to validation above, but default to SSE - transportType = "sse"; - } - } else { - // Auto-detect from URL path - if (url.pathname.endsWith("/mcp")) { - transportType = "streamable-http"; - } else if (url.pathname.endsWith("/sse")) { - transportType = "sse"; - } else { - // Default to SSE if path doesn't match known patterns - transportType = "sse"; - } - } - - // Create SSE or streamable-http config - if (transportType === "sse") { - const config: SseServerConfig = { - type: "sse", - url: firstTarget, - }; - if (args.headers) { - config.headers = args.headers; - } - return config; - } else { - const config: StreamableHttpServerConfig = { - type: "streamable-http", - url: firstTarget, - }; - if (args.headers) { - config.headers = args.headers; - } - return config; - } - } - - // Handle stdio transport (command-based) - const config: StdioServerConfig = { - type: "stdio", - command: firstTarget, - }; - - if (targetArgs.length > 0) { - config.args = targetArgs; - } - - const processEnv: Record = {}; - - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - processEnv[key] = value; - } - } - - const defaultEnv = getDefaultEnvironment(); - - const env: Record = { - ...defaultEnv, - ...processEnv, - }; - - config.env = env; - - if (args.cwd?.trim()) { - config.cwd = args.cwd.trim(); - } - - return config; -} - -async function callMethod(args: Args): Promise { - // Read package.json to get name and version for client identity - const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli - const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector - const packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { +async function callMethod( + serverConfig: MCPServerConfig, + args: MethodArgs & { method: string }, +): Promise { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const packageJsonPath = join(__dirname, "../package.json"); + const packageJsonData = await import(pathToFileURL(packageJsonPath).href, { with: { type: "json" }, }); const packageJson = packageJsonData.default as { @@ -182,15 +58,15 @@ async function callMethod(args: Args): Promise { const version = packageJson.version; const clientIdentity = { name, version }; - const inspectorClient = new InspectorClient(argsToMcpServerConfig(args), { + const inspectorClient = new InspectorClient(serverConfig, { environment: { transport: createTransportNode, }, clientIdentity, - initialLoggingLevel: "debug", // Set debug logging level for CLI - progress: false, // CLI doesn't use progress; avoids SDK injecting progressToken into _meta - sample: false, // CLI doesn't need sampling capability - elicit: false, // CLI doesn't need elicitation capability + initialLoggingLevel: "debug", + progress: false, + sample: false, + elicit: false, }); let managedToolsState: ManagedToolsState | null = null; @@ -204,14 +80,12 @@ async function callMethod(args: Args): Promise { let result: McpResponse; - // Tools methods: use ManagedToolsState for both tools/list and tools/call if (args.method === "tools/list" || args.method === "tools/call") { managedToolsState = new ManagedToolsState(inspectorClient); managedToolsState.setMetadata(args.metadata); await managedToolsState.refresh(); } - // Resources / resource templates / prompts: use managed state when listing if (args.method === "resources/list") { managedResourcesState = new ManagedResourcesState(inspectorClient); managedResourcesState.setMetadata(args.metadata); @@ -241,7 +115,6 @@ async function callMethod(args: Args): Promise { .getTools() .find((t) => t.name === args.toolName); if (!tool) { - // Same result shape as server error (so CLI output and tests unchanged) result = { content: [ { @@ -258,7 +131,6 @@ async function callMethod(args: Args): Promise { args.metadata, args.toolMeta, ); - // Extract the result from the invocation object for CLI compatibility if (invocation.result !== null) { result = invocation.result; } else { @@ -273,9 +145,7 @@ async function callMethod(args: Args): Promise { }; } } - } - // Resources methods - else if (args.method === "resources/list") { + } else if (args.method === "resources/list") { result = { resources: managedResourcesState!.getResources(), }; @@ -290,16 +160,13 @@ async function callMethod(args: Args): Promise { args.uri, args.metadata, ); - // Extract the result from the invocation object for CLI compatibility result = invocation.result; } else if (args.method === "resources/templates/list") { result = { resourceTemplates: managedResourceTemplatesState!.getResourceTemplates(), }; - } - // Prompts methods - else if (args.method === "prompts/list") { + } else if (args.method === "prompts/list") { result = { prompts: managedPromptsState!.getPrompts() }; } else if (args.method === "prompts/get") { if (!args.promptName) { @@ -313,11 +180,8 @@ async function callMethod(args: Args): Promise { args.promptArgs || {}, args.metadata, ); - // Extract the result from the invocation object for CLI compatibility result = invocation.result; - } - // Logging methods - else if (args.method === "logging/setLevel") { + } else if (args.method === "logging/setLevel") { if (!args.logLevel) { throw new Error( "Log level is required for logging/setLevel method. Use --log-level to specify the log level.", @@ -350,72 +214,65 @@ function parseKeyValuePair( const key = parts[0]; const val = parts.slice(1).join("="); - if (val === undefined || val === "") { + if (!key || val === undefined || val === "") { throw new Error( `Invalid parameter format: ${value}. Use key=value format.`, ); } - // Try to parse as JSON first let parsedValue: JsonValue; try { parsedValue = JSON.parse(val) as JsonValue; } catch { - // If JSON parsing fails, keep as string parsedValue = val; } return { ...previous, [key as string]: parsedValue }; } -function parseHeaderPair( - value: string, - previous: Record = {}, -): Record { - const colonIndex = value.indexOf(":"); - - if (colonIndex === -1) { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - const key = value.slice(0, colonIndex).trim(); - const val = value.slice(colonIndex + 1).trim(); - - if (key === "" || val === "") { - throw new Error( - `Invalid header format: ${value}. Use "HeaderName: Value" format.`, - ); - } - - return { ...previous, [key]: val }; -} - -function parseArgs(): Args { +function parseArgs(argv?: string[]): { + serverConfig: MCPServerConfig; + methodArgs: MethodArgs & { method: string }; +} { const program = new Command(); - - // Find if there's a -- in the arguments and split them - const argSeparatorIndex = process.argv.indexOf("--"); - let preArgs = process.argv; - let postArgs: string[] = []; - - if (argSeparatorIndex !== -1) { - preArgs = process.argv.slice(0, argSeparatorIndex); - postArgs = process.argv.slice(argSeparatorIndex + 1); + const rawArgs = argv ?? process.argv; + const scriptArgs = rawArgs.slice(2); + const dashDashIndex = scriptArgs.indexOf("--"); + let targetArgs: string[] = []; + let optionArgs: string[] = []; + if (dashDashIndex >= 0) { + targetArgs = scriptArgs.slice(0, dashDashIndex); + optionArgs = scriptArgs.slice(dashDashIndex + 1); + } else { + let i = 0; + while (i < scriptArgs.length && !scriptArgs[i]!.startsWith("-")) { + targetArgs.push(scriptArgs[i]!); + i++; + } + optionArgs = scriptArgs.slice(i); } + const preArgs: string[] = [ + rawArgs[0] ?? "node", + rawArgs[1] ?? "inspector-cli", + ...optionArgs, + ]; program .name("inspector-cli") .allowUnknownOption() - .argument("", "Command and arguments or URL of the MCP server") - // - // Method selection - // + .argument( + "[target...]", + "Command and arguments or URL of the MCP server (or use --config and --server)", + ) + .option("--config ", "Config file path") + .option("--server ", "Server name from config file") + .option( + "-e ", + "Environment variables for the server (KEY=VALUE)", + parseEnvPair, + {}, + ) .option("--method ", "Method to invoke") - // - // Tool-related options - // .option("--tool-name ", "Tool name (for tools/call method)") .option( "--tool-arg ", @@ -423,13 +280,7 @@ function parseArgs(): Args { parseKeyValuePair, {}, ) - // - // Resource-related options - // .option("--uri ", "URI of the resource (for resources/read method)") - // - // Prompt-related options - // .option( "--prompt-name ", "Name of the prompt (for prompts/get method)", @@ -440,9 +291,6 @@ function parseArgs(): Args { parseKeyValuePair, {}, ) - // - // Logging options - // .option( "--log-level ", "Logging level (for logging/setLevel method)", @@ -452,13 +300,9 @@ function parseArgs(): Args { `Invalid log level: ${value}. Valid levels are: ${validLogLevels.join(", ")}`, ); } - return value as LoggingLevel; }, ) - // - // Transport options - // .option("--cwd ", "Working directory for stdio server process") .option( "--transport ", @@ -473,18 +317,13 @@ function parseArgs(): Args { return value as "sse" | "http" | "stdio"; }, ) - // - // HTTP headers - // + .option("--server-url ", "Server URL for SSE/HTTP transport") .option( "--header ", 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', parseHeaderPair, {}, ) - // - // Metadata options - // .option( "--metadata ", "General metadata as key=value pairs (applied to all methods)", @@ -498,19 +337,45 @@ function parseArgs(): Args { {}, ); - // Parse only the arguments before -- program.parse(preArgs); - const options = program.opts() as Omit & { - header?: Record; + const options = program.opts() as { + config?: string; + server?: string; + e?: Record; + method?: string; + toolName?: string; + toolArg?: Record; + uri?: string; + promptName?: string; + promptArgs?: Record; + logLevel?: LoggingLevel; metadata?: Record; toolMetadata?: Record; + cwd?: string; + transport?: "sse" | "http" | "stdio"; + serverUrl?: string; + header?: Record; }; - const remainingArgs = program.args; + const serverOptions = { + configPath: options.config, + serverName: options.server, + target: targetArgs.length > 0 ? targetArgs : undefined, + transport: options.transport, + serverUrl: options.serverUrl, + cwd: options.cwd, + env: options.e, + headers: options.header, + }; - // Add back any arguments that came after -- - const finalArgs = [...remainingArgs, ...postArgs]; + const configs = resolveServerConfigs(serverOptions, "single"); + const serverConfig = configs[0]; + if (!serverConfig) { + throw new Error( + "Could not resolve server config. Specify a URL or command, or use --config and --server.", + ); + } if (!options.method) { throw new Error( @@ -518,11 +383,14 @@ function parseArgs(): Args { ); } - return { - target: finalArgs, - ...options, - cwd: options.cwd, - headers: options.header, // commander.js uses 'header' field, map to 'headers' + const methodArgs: MethodArgs & { method: string } = { + method: options.method, + toolName: options.toolName, + toolArg: options.toolArg, + uri: options.uri, + promptName: options.promptName, + promptArgs: options.promptArgs, + logLevel: options.logLevel, metadata: options.metadata ? Object.fromEntries( Object.entries(options.metadata).map(([key, value]) => [ @@ -540,22 +408,24 @@ function parseArgs(): Args { ) : undefined, }; + + return { serverConfig, methodArgs }; +} + +export async function runCli(argv?: string[]): Promise { + const { serverConfig, methodArgs } = parseArgs(argv ?? process.argv); + await callMethod(serverConfig, methodArgs); } -async function main(): Promise { +export async function main(): Promise { process.on("uncaughtException", (error) => { handleError(error); }); try { - const args = parseArgs(); - await callMethod(args); - - // Explicitly exit to ensure process terminates in CI + await runCli(); process.exit(0); } catch (error) { handleError(error); } } - -main(); diff --git a/cli/src/error-handler.ts b/clients/cli/src/error-handler.ts similarity index 100% rename from cli/src/error-handler.ts rename to clients/cli/src/error-handler.ts diff --git a/clients/cli/src/index.ts b/clients/cli/src/index.ts new file mode 100644 index 000000000..93c1fa228 --- /dev/null +++ b/clients/cli/src/index.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { runCli, validLogLevels } from "./cli.js"; +import { handleError } from "./error-handler.js"; + +export { runCli, validLogLevels }; + +const __filename = fileURLToPath(import.meta.url); +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(__filename); + +if (isMain) { + runCli(process.argv) + .then(() => process.exit(0)) + .catch(handleError); +} diff --git a/cli/src/utils/awaitable-log.ts b/clients/cli/src/utils/awaitable-log.ts similarity index 100% rename from cli/src/utils/awaitable-log.ts rename to clients/cli/src/utils/awaitable-log.ts diff --git a/cli/tsconfig.json b/clients/cli/tsconfig.json similarity index 91% rename from cli/tsconfig.json rename to clients/cli/tsconfig.json index 6baeb1a50..acc41cef3 100644 --- a/cli/tsconfig.json +++ b/clients/cli/tsconfig.json @@ -14,5 +14,5 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "packages", "**/*.spec.ts", "build"], - "references": [{ "path": "../core" }] + "references": [{ "path": "../../core" }] } diff --git a/cli/vitest.config.ts b/clients/cli/vitest.config.ts similarity index 100% rename from cli/vitest.config.ts rename to clients/cli/vitest.config.ts diff --git a/clients/launcher/README.md b/clients/launcher/README.md new file mode 100644 index 000000000..51d41cdc3 --- /dev/null +++ b/clients/launcher/README.md @@ -0,0 +1,15 @@ +# MCP Inspector Launcher + +The launcher is the package that provides the global `mcp-inspector` binary (e.g. when users run `npx @modelcontextprotocol/inspector`). It is not a separate user-facing app—it is the single entrypoint that selects and runs one of the clients (web, CLI, or TUI). + +## Responsibility + +- Parse the mode flag: `--web` (default), `--cli`, or `--tui`. +- Forward the rest of `process.argv` to the chosen app. +- Dynamically import that app’s runner and call it **in-process** (no `spawn()`). + +All configuration parsing, config-file loading, and server setup are handled by the app runners and by **core**; the launcher does not interpret config or env vars. + +## Architecture + +For how the launcher fits with the shared config processor and app runners, see the [Launcher and config consolidation](../../docs/launcher-config-consolidation-plan.md) document in the repo root `docs/` folder. diff --git a/clients/launcher/package.json b/clients/launcher/package.json new file mode 100644 index 000000000..6c2dc35e4 --- /dev/null +++ b/clients/launcher/package.json @@ -0,0 +1,29 @@ +{ + "name": "@modelcontextprotocol/inspector-launcher", + "version": "0.20.0", + "description": "Launcher for MCP Inspector (web, CLI, TUI)", + "license": "MIT", + "type": "module", + "main": "build/index.js", + "bin": { + "mcp-inspector": "./build/index.js" + }, + "files": [ + "build" + ], + "scripts": { + "build": "tsc", + "postbuild": "node scripts/make-executable.js", + "lint": "prettier --check . && echo 'Lint passed.'" + }, + "dependencies": { + "@modelcontextprotocol/inspector-web": "*", + "@modelcontextprotocol/inspector-cli": "*", + "@modelcontextprotocol/inspector-tui": "*", + "commander": "^13.1.0" + }, + "devDependencies": { + "@types/node": "^22.17.0", + "typescript": "^5.4.2" + } +} diff --git a/cli/scripts/make-executable.js b/clients/launcher/scripts/make-executable.js old mode 100755 new mode 100644 similarity index 67% rename from cli/scripts/make-executable.js rename to clients/launcher/scripts/make-executable.js index f3b8c9024..0ddb70a41 --- a/cli/scripts/make-executable.js +++ b/clients/launcher/scripts/make-executable.js @@ -1,22 +1,19 @@ /** - * Cross-platform script to make a file executable + * Cross-platform script to make the launcher entrypoint executable */ import { promises as fs } from "fs"; import { platform } from "os"; import { execSync } from "child_process"; import path from "path"; -const TARGET_FILE = path.resolve("build/cli.js"); +const TARGET_FILE = path.resolve("build/index.js"); async function makeExecutable() { try { - // On Unix-like systems (Linux, macOS), use chmod if (platform() !== "win32") { execSync(`chmod +x "${TARGET_FILE}"`); console.log("Made file executable with chmod"); } else { - // On Windows, no need to make files "executable" in the Unix sense - // Just ensure the file exists await fs.access(TARGET_FILE); console.log("File exists and is accessible on Windows"); } diff --git a/clients/launcher/src/app-runners.d.ts b/clients/launcher/src/app-runners.d.ts new file mode 100644 index 000000000..fa00baca7 --- /dev/null +++ b/clients/launcher/src/app-runners.d.ts @@ -0,0 +1,11 @@ +declare module "@modelcontextprotocol/inspector-web" { + export function runWeb(argv?: string[]): Promise; +} + +declare module "@modelcontextprotocol/inspector-cli" { + export function runCli(argv?: string[]): Promise; +} + +declare module "@modelcontextprotocol/inspector-tui" { + export function runTui(argv?: string[]): Promise; +} diff --git a/clients/launcher/src/index.ts b/clients/launcher/src/index.ts new file mode 100644 index 000000000..aecb91188 --- /dev/null +++ b/clients/launcher/src/index.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { Command } from "commander"; + +const program = new Command(); + +program + .name("mcp-inspector") + .description("MCP Inspector – run web UI, CLI, or TUI") + .option("--web", "Run web UI (default)") + .option("--cli", "Run CLI") + .option("--tui", "Run TUI") + .allowUnknownOption(); + +program.parseOptions(process.argv); +const opts = program.opts() as { web?: boolean; cli?: boolean; tui?: boolean }; + +const helpOnly = process.argv.includes("-h") || process.argv.includes("--help"); +const modeFlagSet = opts.web || opts.cli || opts.tui; + +if (helpOnly && !modeFlagSet) { + program.outputHelp(); + console.log( + "\nAll other arguments are forwarded to the selected app. Use --web, --cli, or --tui then pass app-specific options.", + ); + process.exit(0); +} + +const mode = opts.tui ? "tui" : opts.cli ? "cli" : "web"; +const modeFlag = opts.tui ? "--tui" : opts.cli ? "--cli" : "--web"; +// Forward argv without the launcher's mode flag so the app's Commander doesn't see unknown option +const forwardedArgv = process.argv.filter((arg) => arg !== modeFlag); + +async function run(): Promise { + if (mode === "web") { + const { runWeb } = await import("@modelcontextprotocol/inspector-web"); + await runWeb(forwardedArgv); + } else if (mode === "cli") { + const { runCli } = await import("@modelcontextprotocol/inspector-cli"); + await runCli(forwardedArgv); + } else { + const { runTui } = await import("@modelcontextprotocol/inspector-tui"); + await runTui(forwardedArgv); + } +} + +run().catch((err: unknown) => { + console.error("Error running MCP Inspector:", err); + process.exit(1); +}); diff --git a/clients/launcher/tsconfig.json b/clients/launcher/tsconfig.json new file mode 100644 index 000000000..9eaa88d7d --- /dev/null +++ b/clients/launcher/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@modelcontextprotocol/inspector-web": ["./src/app-runners.d.ts"], + "@modelcontextprotocol/inspector-cli": ["./src/app-runners.d.ts"], + "@modelcontextprotocol/inspector-tui": ["./src/app-runners.d.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/clients/tui/README.md b/clients/tui/README.md new file mode 100644 index 000000000..fc53e56ed --- /dev/null +++ b/clients/tui/README.md @@ -0,0 +1,55 @@ +# MCP Inspector TUI Client + +The Terminal User Interface (TUI) client brings the interactive exploration capabilities of the Web Client directly to your terminal. It is built using [Ink](https://github.com/vadimdemedes/ink) to provide a rich, React-like component experience in a command-line environment. + +## Running the TUI + +You can run the TUI client via `npx`: + +```bash +npx @modelcontextprotocol/inspector --tui node build/index.js +``` + +### With Configuration Files + +The TUI can load all servers from an MCP config file: + +```bash +npx @modelcontextprotocol/inspector --tui --config mcp.json +``` + +(It does not use `--server`; all servers in the file are available in the TUI.) + +## Options + +### MCP server (which server(s) to connect to) + +Options that specify the MCP server(s) (config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--config`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. + +### TUI-specific (OAuth for HTTP servers) + +When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass: + +| Option | Description | +| ----------------------------- | ------------------------------------------------------------------------------------ | +| `--client-id ` | OAuth client ID (static client). | +| `--client-secret ` | OAuth client secret (confidential clients). | +| `--client-metadata-url ` | OAuth Client ID Metadata Document URL (CIMD). | +| `--callback-url ` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). | + +## Features + +The TUI provides terminal-native tabs and panes for interacting with your MCP server: + +- **Resources**: Browse and read resources exposed by the server. +- **Prompts**: List and test prompts. +- **Tools**: View available tools and execute them with form-like inputs. +- **History**: View the request and response history of your interactions. +- **Console**: View the direct stdout/stderr and diagnostic logging of the connected server. + +## Navigation + +- Use the **Arrow Keys** (Left/Right) or **Tab** to switch between the main tabs (Resources, Tools, Prompts, etc.). +- Use the **Arrow Keys** (Up/Down) to scroll through lists of items. +- Press **Enter** to select an item, execute a tool, or fetch a resource. +- Press **Escape** or `Ctrl+C` to exit the application. diff --git a/tui/__tests__/tui.test.ts b/clients/tui/__tests__/tui.test.ts similarity index 100% rename from tui/__tests__/tui.test.ts rename to clients/tui/__tests__/tui.test.ts diff --git a/clients/tui/index.ts b/clients/tui/index.ts new file mode 100644 index 000000000..46f6d8fed --- /dev/null +++ b/clients/tui/index.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { runTui } from "./tui.js"; + +export { runTui }; + +const __filename = fileURLToPath(import.meta.url); +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(__filename); + +if (isMain) { + runTui(process.argv).catch((err: unknown) => { + console.error(err); + process.exit(1); + }); +} diff --git a/tui/package.json b/clients/tui/package.json similarity index 87% rename from tui/package.json rename to clients/tui/package.json index b360e2cdd..350300436 100644 --- a/tui/package.json +++ b/clients/tui/package.json @@ -10,16 +10,16 @@ "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "type": "module", - "main": "build/tui.js", + "main": "build/index.js", "bin": { - "mcp-inspector-tui": "./build/tui.js" + "mcp-inspector-tui": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc", - "dev": "NODE_PATH=../node_modules:./node_modules:$NODE_PATH tsx tui.tsx", + "dev": "NODE_PATH=../../node_modules:./node_modules:$NODE_PATH tsx tui.tsx", "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "test": "vitest run" }, diff --git a/tui/src/App.tsx b/clients/tui/src/App.tsx similarity index 98% rename from tui/src/App.tsx rename to clients/tui/src/App.tsx index c02cda563..6950361f3 100644 --- a/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -33,10 +33,7 @@ import { FetchRequestLogState, StderrLogState, } from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; -import { - loadMcpServersConfig, - createTransportNode, -} from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; +import { createTransportNode } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; import { useManagedTools } from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; import { useManagedResources } from "@modelcontextprotocol/inspector-core/react/useManagedResources.js"; @@ -115,7 +112,7 @@ type FocusArea = | "requestsDetail"; interface AppProps { - configFile: string; + mcpServers: Record; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; @@ -134,7 +131,7 @@ function isOAuthCapableServer(config: MCPServerConfig | null): boolean { } function App({ - configFile, + mcpServers, clientId, clientSecret, clientMetadataUrl, @@ -151,8 +148,8 @@ function App({ ); useEffect(() => { - tuiLogger.info({ configFile }, "TUI started"); - }, [configFile]); + tuiLogger.info({ serverNames: Object.keys(mcpServers) }, "TUI started"); + }, [mcpServers]); const [selectedServer, setSelectedServer] = useState(null); const [activeTab, setActiveTab] = useState("info"); @@ -247,23 +244,9 @@ function App({ }; }, []); - // Parse MCP configuration - const mcpConfig = useMemo(() => { - try { - return loadMcpServersConfig(configFile); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error("Error loading configuration: Unknown error"); - } - process.exit(1); - } - }, [configFile]); - - const serverNames = Object.keys(mcpConfig.mcpServers); + const serverNames = Object.keys(mcpServers); const selectedServerConfig = selectedServer - ? mcpConfig.mcpServers[selectedServer] + ? mcpServers[selectedServer] : null; // Mutable redirect URL providers, keyed by server name (populated before authenticate) @@ -286,9 +269,7 @@ function App({ const newStderrLogStates: Record = {}; for (const serverName of serverNames) { if (!(serverName in inspectorClients)) { - const serverConfig = mcpConfig.mcpServers[ - serverName - ] as MCPServerConfig & { + const serverConfig = mcpServers[serverName] as MCPServerConfig & { oauth?: Record; }; const environment: InspectorClientEnvironment = { diff --git a/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx similarity index 100% rename from tui/src/components/AuthTab.tsx rename to clients/tui/src/components/AuthTab.tsx diff --git a/tui/src/components/DetailsModal.tsx b/clients/tui/src/components/DetailsModal.tsx similarity index 100% rename from tui/src/components/DetailsModal.tsx rename to clients/tui/src/components/DetailsModal.tsx diff --git a/tui/src/components/HistoryTab.tsx b/clients/tui/src/components/HistoryTab.tsx similarity index 100% rename from tui/src/components/HistoryTab.tsx rename to clients/tui/src/components/HistoryTab.tsx diff --git a/tui/src/components/InfoTab.tsx b/clients/tui/src/components/InfoTab.tsx similarity index 100% rename from tui/src/components/InfoTab.tsx rename to clients/tui/src/components/InfoTab.tsx diff --git a/tui/src/components/ModalBackdrop.tsx b/clients/tui/src/components/ModalBackdrop.tsx similarity index 100% rename from tui/src/components/ModalBackdrop.tsx rename to clients/tui/src/components/ModalBackdrop.tsx diff --git a/tui/src/components/NotificationsTab.tsx b/clients/tui/src/components/NotificationsTab.tsx similarity index 100% rename from tui/src/components/NotificationsTab.tsx rename to clients/tui/src/components/NotificationsTab.tsx diff --git a/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx similarity index 100% rename from tui/src/components/PromptTestModal.tsx rename to clients/tui/src/components/PromptTestModal.tsx diff --git a/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx similarity index 100% rename from tui/src/components/PromptsTab.tsx rename to clients/tui/src/components/PromptsTab.tsx diff --git a/tui/src/components/RequestsTab.tsx b/clients/tui/src/components/RequestsTab.tsx similarity index 100% rename from tui/src/components/RequestsTab.tsx rename to clients/tui/src/components/RequestsTab.tsx diff --git a/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx similarity index 100% rename from tui/src/components/ResourceTestModal.tsx rename to clients/tui/src/components/ResourceTestModal.tsx diff --git a/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx similarity index 100% rename from tui/src/components/ResourcesTab.tsx rename to clients/tui/src/components/ResourcesTab.tsx diff --git a/tui/src/components/SelectableItem.tsx b/clients/tui/src/components/SelectableItem.tsx similarity index 100% rename from tui/src/components/SelectableItem.tsx rename to clients/tui/src/components/SelectableItem.tsx diff --git a/tui/src/components/Tabs.tsx b/clients/tui/src/components/Tabs.tsx similarity index 100% rename from tui/src/components/Tabs.tsx rename to clients/tui/src/components/Tabs.tsx diff --git a/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx similarity index 100% rename from tui/src/components/ToolTestModal.tsx rename to clients/tui/src/components/ToolTestModal.tsx diff --git a/tui/src/components/ToolsTab.tsx b/clients/tui/src/components/ToolsTab.tsx similarity index 100% rename from tui/src/components/ToolsTab.tsx rename to clients/tui/src/components/ToolsTab.tsx diff --git a/tui/src/components/tabsConfig.ts b/clients/tui/src/components/tabsConfig.ts similarity index 100% rename from tui/src/components/tabsConfig.ts rename to clients/tui/src/components/tabsConfig.ts diff --git a/tui/src/hooks/useSelectableList.ts b/clients/tui/src/hooks/useSelectableList.ts similarity index 100% rename from tui/src/hooks/useSelectableList.ts rename to clients/tui/src/hooks/useSelectableList.ts diff --git a/tui/src/logger.ts b/clients/tui/src/logger.ts similarity index 100% rename from tui/src/logger.ts rename to clients/tui/src/logger.ts diff --git a/tui/src/utils/openUrl.ts b/clients/tui/src/utils/openUrl.ts similarity index 100% rename from tui/src/utils/openUrl.ts rename to clients/tui/src/utils/openUrl.ts diff --git a/tui/src/utils/promptArgsToForm.ts b/clients/tui/src/utils/promptArgsToForm.ts similarity index 100% rename from tui/src/utils/promptArgsToForm.ts rename to clients/tui/src/utils/promptArgsToForm.ts diff --git a/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts similarity index 100% rename from tui/src/utils/schemaToForm.ts rename to clients/tui/src/utils/schemaToForm.ts diff --git a/tui/src/utils/uriTemplateToForm.ts b/clients/tui/src/utils/uriTemplateToForm.ts similarity index 100% rename from tui/src/utils/uriTemplateToForm.ts rename to clients/tui/src/utils/uriTemplateToForm.ts diff --git a/tui/test-config.json b/clients/tui/test-config.json similarity index 100% rename from tui/test-config.json rename to clients/tui/test-config.json diff --git a/tui/tsconfig.json b/clients/tui/tsconfig.json similarity index 80% rename from tui/tsconfig.json rename to clients/tui/tsconfig.json index 9c5f0e4e3..24cce84d3 100644 --- a/tui/tsconfig.json +++ b/clients/tui/tsconfig.json @@ -12,7 +12,7 @@ "outDir": "./build", "rootDir": "." }, - "include": ["src/**/*", "tui.tsx"], + "include": ["src/**/*", "tui.tsx", "index.ts"], "exclude": ["node_modules", "build"], - "references": [{ "path": "../core" }] + "references": [{ "path": "../../core" }] } diff --git a/tui/tui.tsx b/clients/tui/tui.tsx similarity index 66% rename from tui/tui.tsx rename to clients/tui/tui.tsx index 4c15618f7..c4960254a 100755 --- a/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -2,6 +2,13 @@ import { Command } from "commander"; import { render } from "ink"; +import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import { + getNamedServerConfigs, + resolveServerConfigs, + parseKeyValuePair, + parseHeaderPair, +} from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; import App from "./src/App.js"; export async function runTui(args?: string[]): Promise { @@ -10,7 +17,23 @@ export async function runTui(args?: string[]): Promise { program .name("mcp-inspector-tui") .description("Terminal UI for MCP Inspector") - .argument("", "path to MCP servers config file") + .option( + "--config ", + "Path to MCP servers config file (or use ad-hoc server options below)", + ) + .option( + "-e ", + "Environment variables for stdio servers", + parseKeyValuePair, + {}, + ) + .option("--cwd ", "Working directory for stdio servers") + .option( + "--header ", + 'HTTP headers as "Name: Value"', + parseHeaderPair, + {}, + ) .option( "--client-id ", "OAuth client ID (static client) for HTTP servers", @@ -27,18 +50,59 @@ export async function runTui(args?: string[]): Promise { "--callback-url ", "OAuth redirect/callback listener URL (default: http://127.0.0.1:0/oauth/callback)", ) + .argument( + "[target...]", + "Command and args or URL for a single ad-hoc server (when not using --config)", + ) + .option( + "--transport ", + "Transport: stdio, sse, or http (ad-hoc only)", + ) + .option("--server-url ", "Server URL (ad-hoc only)") .parse(args ?? process.argv); - const configFile = program.args[0]; const options = program.opts() as { + config?: string; + e?: Record; + cwd?: string; + header?: Record; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; callbackUrl?: string; + transport?: "stdio" | "sse" | "http"; + serverUrl?: string; + }; + const targetArgs = program.args as string[]; + + let mcpServers: Record; + const serverOptions = { + configPath: options.config?.trim() || undefined, + target: targetArgs.length > 0 ? targetArgs : undefined, + cwd: options.cwd?.trim() || undefined, + env: options.e, + headers: options.header, + transport: options.transport, + serverUrl: options.serverUrl?.trim() || undefined, }; - if (!configFile) { - program.error("Config file is required"); + try { + if (serverOptions.configPath) { + mcpServers = getNamedServerConfigs(serverOptions); + } else { + const configs = resolveServerConfigs(serverOptions, "multi"); + if (configs.length === 0) { + program.error( + "At least one server is required. Use --config or ad-hoc target (command/URL).", + ); + } + mcpServers = { default: configs[0]! }; + } + } catch (err) { + if (err instanceof Error) { + program.error(err.message); + } + throw err; } interface CallbackUrlConfig { @@ -58,16 +122,16 @@ export async function runTui(args?: string[]): Promise { program.error( `Invalid callback URL: ${(err as Error)?.message ?? String(err)}`, ); - return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; // never reached; program.error() throws } if (url.protocol !== "http:") { program.error("Callback URL must use http scheme"); - return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; // never reached; program.error() throws } const hostname = url.hostname; if (!hostname) { program.error("Callback URL must include a hostname"); - return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; + return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; // never reached; program.error() throws } const pathname = url.pathname || "/"; let port: number; @@ -141,7 +205,7 @@ export async function runTui(args?: string[]): Promise { // Render the app const instance = render( { if (process.stdout.isTTY) { process.stdout.write("\x1b[?1049l"); } - console.error("Error:", error); + console.error("TUI Error:", error); process.exit(1); } } - -runTui(); diff --git a/tui/vitest.config.ts b/clients/tui/vitest.config.ts similarity index 100% rename from tui/vitest.config.ts rename to clients/tui/vitest.config.ts diff --git a/web/.gitignore b/clients/web/.gitignore similarity index 100% rename from web/.gitignore rename to clients/web/.gitignore diff --git a/clients/web/README.md b/clients/web/README.md new file mode 100644 index 000000000..a727d2c90 --- /dev/null +++ b/clients/web/README.md @@ -0,0 +1,142 @@ +# MCP Inspector Web Client + +The Web Client is a React-based application that provides a rich, interactive browser UI for exploring, testing, and debugging MCP servers. It is the primary interface for most users. + +![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) + +## Running the Web Inspector + +### Quick Start + +To start the Web UI from npm: + +```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 a local MCP server implementation, run the inspector and pass your server's run command: + +```bash +# E.g., for a Node.js server built at build/index.js +npx @modelcontextprotocol/inspector node build/index.js +``` + +You can pass both arguments and environment variables to your MCP server. Arguments are passed directly, while environment variables can be set using the `-e` flag: + +```bash +# Pass arguments only +npx @modelcontextprotocol/inspector node build/index.js arg1 arg2 + +# Pass environment variables only +npx @modelcontextprotocol/inspector -e key=value -e key2=$VALUE2 node build/index.js + +# Use -- to separate inspector flags from server arguments +npx @modelcontextprotocol/inspector -e key=$VALUE -- node build/index.js -e server-flag +``` + +The inspector runs the **main web server** (UI and API; default port 6274) and a **sandbox server** for MCP Apps (default: automatic port; you can fix it with `MCP_SANDBOX_PORT`). Customize ports via environment variables: + +```bash +CLIENT_PORT=8080 MCP_SANDBOX_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js +``` + +### Docker Container + +You can start the web inspector in a Docker container with the following command: + +```bash +docker run --rm \ + -p 127.0.0.1:6274:6274 \ + -p 127.0.0.1:6277:6277 \ + -e HOST=0.0.0.0 \ + -e MCP_AUTO_OPEN_ENABLED=false \ + ghcr.io/modelcontextprotocol/inspector:latest +``` + +## Configuring the web app + +These settings control the **web server and UI** (host, port, auth, sandbox, etc.). They are separate from [MCP server configuration](../../docs/mcp-server-configuration.md) (which server to connect to). + +All of these are set via **environment variables**; the web app has no command-line flags for port, host, auth, or sandbox. Where a setting had both env and CLI, the CLI would override—today the only web-specific CLI option is `--dev` (development mode). Set env vars in your shell or in the same line as the command (e.g. `CLIENT_PORT=8080 npx @modelcontextprotocol/inspector`). + +| Setting | How to set | Default | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Main server port** | Env: `CLIENT_PORT` | `6274` | +| **Host** (bind address) | Env: `HOST` | `localhost` | +| **Auth token** | Env: `MCP_INSPECTOR_API_TOKEN`; if unset, `MCP_PROXY_AUTH_TOKEN` (legacy). If both unset, a random token is generated and printed. | (random if unset) | +| **Disable auth** | Env: `DANGEROUSLY_OMIT_AUTH` (any non-empty value). **Dangerous;** see Security below. | (unset) | +| **Sandbox port** (MCP Apps) | Env: `MCP_SANDBOX_PORT`; if unset, `SERVER_PORT` (legacy). Use `0` or leave unset for an automatic port. | automatic | +| **Storage directory** (e.g. OAuth) | Env: `MCP_STORAGE_DIR` | (unset) | +| **Allowed origins** (CORS) | Env: `ALLOWED_ORIGINS` (comma-separated) | client origin only | +| **Log file** | Env: `MCP_LOG_FILE` — if set, server logs are appended to this file. | (unset) | +| **Open browser on start** | Env: `MCP_AUTO_OPEN_ENABLED` — set to `false` to disable. | `true` | +| **Development mode** | CLI only: `--dev` (Vite with HMR). No env var. | off | + +Options that specify **which MCP server** to connect to (`--config`, `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and positional command/URL) are shared by Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md). + +## Security Considerations + +The MCP Inspector proxy server runs and communicates with local MCP processes. It should **not** be exposed to untrusted networks, as it can spawn local processes and connect to any specified MCP server. + +### Authentication + +The proxy server requires authentication by default. When starting the server, a random session token is generated: + +``` +šŸ”‘ Session token: +šŸ”— Open inspector with token pre-filled: http://localhost:6274/?MCP_INSPECTOR_API_TOKEN= +``` + +This token must be included as a Bearer token in the Authorization header. By default, the inspector automatically opens your browser with the token pre-filled in the URL. + +If you already have the UI open, click the **Configuration** button in the sidebar, find "Proxy Session Token", and enter the displayed token. + +> **🚨 WARNING:** Disabling authentication with `DANGEROUSLY_OMIT_AUTH=true` is incredibly dangerous. It leaves your machine open to attacks even via your web browser (e.g., visiting a malicious website). Do not disable this feature unless you truly understand the risks. + +### Local-only Binding + +By default, the web client and proxy server bind only to `localhost`. If you need to bind to all interfaces for development, you can override this with the `HOST` environment variable (`HOST=0.0.0.0`), but only do so in trusted networks. + +### DNS Rebinding Protection + +To prevent DNS rebinding attacks, the Inspector validates the `Origin` header on incoming requests. By default, only requests from the client origin are allowed. You can configure additional allowed origins using the `ALLOWED_ORIGINS` environment variable (comma-separated). + +## Configuration + +### Settings (browser UI) + +Click the **Configuration** button in the UI to adjust these. They are stored in the browser and can be overridden via URL query params (see below). + +| Setting | Description | Default | +| --------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | Client-side timeout (ms) – Inspector cancels the request if no response is received. | 300000 | +| `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). | 60000 | +| API Token (Proxy Session Token) | Auth token for the Inspector API; also set via `MCP_INSPECTOR_API_TOKEN` env var or query param. | (from server or query) | +| `MCP_TASK_TTL` | Default TTL (ms) for newly created tasks. | 60000 | + +_Note on timeouts:_ These control when the Inspector cancels requests; they are independent of the MCP server’s own timeouts. + +### MCP server (config file and CLI options) + +To use a config file (`--config`, `--server`) or ad-hoc options (`-e`, `--cwd`, `--header`, positional command/URL), see [MCP server configuration](../../docs/mcp-server-configuration.md). + +### Servers File Export + +The UI provides convenient buttons to export server launch configurations (usually for an `mcp.json` file used by tools like Cursor or Claude Code): + +- **Server Entry**: Copies a single server configuration entry to your clipboard. +- **Servers File**: Copies a complete MCP configuration file structure with your server as `default-server`. + +## URL Query Parameters + +You can set the initial configuration via query parameters in the browser URL: + +- Server settings: `?transport=sse&serverUrl=http://localhost:8787/sse` or `?transport=stdio&serverCommand=node&serverArgs=index.js` +- Inspector settings: `?MCP_SERVER_REQUEST_TIMEOUT=60000` + +If both the query param and localStorage are set, the query param takes precedence. diff --git a/web/components.json b/clients/web/components.json similarity index 100% rename from web/components.json rename to clients/web/components.json diff --git a/web/e2e/cli-arguments.spec.ts b/clients/web/e2e/cli-arguments.spec.ts similarity index 100% rename from web/e2e/cli-arguments.spec.ts rename to clients/web/e2e/cli-arguments.spec.ts diff --git a/web/e2e/global-teardown.js b/clients/web/e2e/global-teardown.js similarity index 100% rename from web/e2e/global-teardown.js rename to clients/web/e2e/global-teardown.js diff --git a/web/e2e/startup-state.spec.ts b/clients/web/e2e/startup-state.spec.ts similarity index 100% rename from web/e2e/startup-state.spec.ts rename to clients/web/e2e/startup-state.spec.ts diff --git a/web/e2e/transport-type-dropdown.spec.ts b/clients/web/e2e/transport-type-dropdown.spec.ts similarity index 100% rename from web/e2e/transport-type-dropdown.spec.ts rename to clients/web/e2e/transport-type-dropdown.spec.ts diff --git a/web/index.html b/clients/web/index.html similarity index 100% rename from web/index.html rename to clients/web/index.html diff --git a/web/package.json b/clients/web/package.json similarity index 92% rename from web/package.json rename to clients/web/package.json index d44a4f0c3..8e01d6788 100644 --- a/web/package.json +++ b/clients/web/package.json @@ -7,17 +7,18 @@ "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "type": "module", + "main": "build/index.js", "bin": { - "mcp-inspector-web": "./bin/start.js" + "mcp-inspector-web": "./build/index.js" }, "files": [ - "bin", + "build", "dist" ], "scripts": { "dev": "vite --port 6274", "typecheck": "tsc -p tsconfig.app.json", - "build": "npm run typecheck && vite build && tsc -p tsconfig.server.json", + "build": "npm run typecheck && vite build && tsc -p tsconfig.server.json && tsc -p tsconfig.runner.json", "start": "node dist/server.js", "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", "preview": "vite preview --port 6274", @@ -56,7 +57,9 @@ "tailwind-merge": "^2.5.3", "zod": "^3.25.76", "open": "^10.1.0", - "pino": "^9.6.0" + "pino": "^9.6.0", + "commander": "^13.1.0", + "vite": "^7.1.11" }, "devDependencies": { "@eslint/js": "^9.11.1", @@ -79,7 +82,6 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.5.3", "typescript-eslint": "^8.38.0", - "vite": "^7.1.11", "vitest": "^4.0.17" } } diff --git a/web/playwright.config.ts b/clients/web/playwright.config.ts similarity index 99% rename from web/playwright.config.ts rename to clients/web/playwright.config.ts index ef15cb046..e11314893 100644 --- a/web/playwright.config.ts +++ b/clients/web/playwright.config.ts @@ -6,7 +6,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - cwd: "..", + cwd: "../..", command: "npm run dev", url: "http://localhost:6274", reuseExistingServer: !process.env.CI, diff --git a/web/postcss.config.js b/clients/web/postcss.config.js similarity index 100% rename from web/postcss.config.js rename to clients/web/postcss.config.js diff --git a/web/public/mcp.svg b/clients/web/public/mcp.svg similarity index 100% rename from web/public/mcp.svg rename to clients/web/public/mcp.svg diff --git a/web/src/App.css b/clients/web/src/App.css similarity index 100% rename from web/src/App.css rename to clients/web/src/App.css diff --git a/web/src/App.tsx b/clients/web/src/App.tsx similarity index 100% rename from web/src/App.tsx rename to clients/web/src/App.tsx diff --git a/web/src/__mocks__/styleMock.js b/clients/web/src/__mocks__/styleMock.js similarity index 100% rename from web/src/__mocks__/styleMock.js rename to clients/web/src/__mocks__/styleMock.js diff --git a/web/src/__tests__/App.config.test.tsx b/clients/web/src/__tests__/App.config.test.tsx similarity index 100% rename from web/src/__tests__/App.config.test.tsx rename to clients/web/src/__tests__/App.config.test.tsx diff --git a/web/src/__tests__/App.routing.test.tsx b/clients/web/src/__tests__/App.routing.test.tsx similarity index 100% rename from web/src/__tests__/App.routing.test.tsx rename to clients/web/src/__tests__/App.routing.test.tsx diff --git a/web/src/__tests__/App.samplingNavigation.test.tsx b/clients/web/src/__tests__/App.samplingNavigation.test.tsx similarity index 100% rename from web/src/__tests__/App.samplingNavigation.test.tsx rename to clients/web/src/__tests__/App.samplingNavigation.test.tsx diff --git a/web/src/components/AppRenderer.tsx b/clients/web/src/components/AppRenderer.tsx similarity index 100% rename from web/src/components/AppRenderer.tsx rename to clients/web/src/components/AppRenderer.tsx diff --git a/web/src/components/AppsTab.tsx b/clients/web/src/components/AppsTab.tsx similarity index 100% rename from web/src/components/AppsTab.tsx rename to clients/web/src/components/AppsTab.tsx diff --git a/web/src/components/AuthDebugger.tsx b/clients/web/src/components/AuthDebugger.tsx similarity index 100% rename from web/src/components/AuthDebugger.tsx rename to clients/web/src/components/AuthDebugger.tsx diff --git a/web/src/components/ConsoleTab.tsx b/clients/web/src/components/ConsoleTab.tsx similarity index 100% rename from web/src/components/ConsoleTab.tsx rename to clients/web/src/components/ConsoleTab.tsx diff --git a/web/src/components/CustomHeaders.tsx b/clients/web/src/components/CustomHeaders.tsx similarity index 100% rename from web/src/components/CustomHeaders.tsx rename to clients/web/src/components/CustomHeaders.tsx diff --git a/web/src/components/DynamicJsonForm.tsx b/clients/web/src/components/DynamicJsonForm.tsx similarity index 100% rename from web/src/components/DynamicJsonForm.tsx rename to clients/web/src/components/DynamicJsonForm.tsx diff --git a/web/src/components/ElicitationRequest.tsx b/clients/web/src/components/ElicitationRequest.tsx similarity index 100% rename from web/src/components/ElicitationRequest.tsx rename to clients/web/src/components/ElicitationRequest.tsx diff --git a/web/src/components/ElicitationTab.tsx b/clients/web/src/components/ElicitationTab.tsx similarity index 100% rename from web/src/components/ElicitationTab.tsx rename to clients/web/src/components/ElicitationTab.tsx diff --git a/web/src/components/ElicitationUrlRequest.tsx b/clients/web/src/components/ElicitationUrlRequest.tsx similarity index 100% rename from web/src/components/ElicitationUrlRequest.tsx rename to clients/web/src/components/ElicitationUrlRequest.tsx diff --git a/web/src/components/HistoryAndNotifications.tsx b/clients/web/src/components/HistoryAndNotifications.tsx similarity index 100% rename from web/src/components/HistoryAndNotifications.tsx rename to clients/web/src/components/HistoryAndNotifications.tsx diff --git a/web/src/components/IconDisplay.tsx b/clients/web/src/components/IconDisplay.tsx similarity index 100% rename from web/src/components/IconDisplay.tsx rename to clients/web/src/components/IconDisplay.tsx diff --git a/web/src/components/JsonEditor.tsx b/clients/web/src/components/JsonEditor.tsx similarity index 100% rename from web/src/components/JsonEditor.tsx rename to clients/web/src/components/JsonEditor.tsx diff --git a/web/src/components/JsonView.tsx b/clients/web/src/components/JsonView.tsx similarity index 100% rename from web/src/components/JsonView.tsx rename to clients/web/src/components/JsonView.tsx diff --git a/web/src/components/ListPane.tsx b/clients/web/src/components/ListPane.tsx similarity index 100% rename from web/src/components/ListPane.tsx rename to clients/web/src/components/ListPane.tsx diff --git a/web/src/components/MetadataTab.tsx b/clients/web/src/components/MetadataTab.tsx similarity index 100% rename from web/src/components/MetadataTab.tsx rename to clients/web/src/components/MetadataTab.tsx diff --git a/web/src/components/OAuthCallback.tsx b/clients/web/src/components/OAuthCallback.tsx similarity index 100% rename from web/src/components/OAuthCallback.tsx rename to clients/web/src/components/OAuthCallback.tsx diff --git a/web/src/components/OAuthDebugCallback.tsx b/clients/web/src/components/OAuthDebugCallback.tsx similarity index 100% rename from web/src/components/OAuthDebugCallback.tsx rename to clients/web/src/components/OAuthDebugCallback.tsx diff --git a/web/src/components/OAuthFlowProgress.tsx b/clients/web/src/components/OAuthFlowProgress.tsx similarity index 100% rename from web/src/components/OAuthFlowProgress.tsx rename to clients/web/src/components/OAuthFlowProgress.tsx diff --git a/web/src/components/PingTab.tsx b/clients/web/src/components/PingTab.tsx similarity index 100% rename from web/src/components/PingTab.tsx rename to clients/web/src/components/PingTab.tsx diff --git a/web/src/components/PromptsTab.tsx b/clients/web/src/components/PromptsTab.tsx similarity index 100% rename from web/src/components/PromptsTab.tsx rename to clients/web/src/components/PromptsTab.tsx diff --git a/web/src/components/RequestsTab.tsx b/clients/web/src/components/RequestsTab.tsx similarity index 100% rename from web/src/components/RequestsTab.tsx rename to clients/web/src/components/RequestsTab.tsx diff --git a/web/src/components/ResourceLinkView.tsx b/clients/web/src/components/ResourceLinkView.tsx similarity index 100% rename from web/src/components/ResourceLinkView.tsx rename to clients/web/src/components/ResourceLinkView.tsx diff --git a/web/src/components/ResourcesTab.tsx b/clients/web/src/components/ResourcesTab.tsx similarity index 100% rename from web/src/components/ResourcesTab.tsx rename to clients/web/src/components/ResourcesTab.tsx diff --git a/web/src/components/RootsTab.tsx b/clients/web/src/components/RootsTab.tsx similarity index 100% rename from web/src/components/RootsTab.tsx rename to clients/web/src/components/RootsTab.tsx diff --git a/web/src/components/SamplingRequest.tsx b/clients/web/src/components/SamplingRequest.tsx similarity index 100% rename from web/src/components/SamplingRequest.tsx rename to clients/web/src/components/SamplingRequest.tsx diff --git a/web/src/components/SamplingTab.tsx b/clients/web/src/components/SamplingTab.tsx similarity index 100% rename from web/src/components/SamplingTab.tsx rename to clients/web/src/components/SamplingTab.tsx diff --git a/web/src/components/Sidebar.tsx b/clients/web/src/components/Sidebar.tsx similarity index 99% rename from web/src/components/Sidebar.tsx rename to clients/web/src/components/Sidebar.tsx index 825b72fd5..f9e94f0a6 100644 --- a/web/src/components/Sidebar.tsx +++ b/clients/web/src/components/Sidebar.tsx @@ -32,7 +32,7 @@ import { import { InspectorConfig } from "@/lib/configurationTypes"; import { ConnectionStatus } from "@/lib/constants"; import useTheme from "../lib/hooks/useTheme"; -import { version } from "../../../package.json"; +import { version } from "../../../../package.json"; import { Tooltip, TooltipTrigger, diff --git a/web/src/components/TasksTab.tsx b/clients/web/src/components/TasksTab.tsx similarity index 100% rename from web/src/components/TasksTab.tsx rename to clients/web/src/components/TasksTab.tsx diff --git a/web/src/components/TokenLoginScreen.tsx b/clients/web/src/components/TokenLoginScreen.tsx similarity index 100% rename from web/src/components/TokenLoginScreen.tsx rename to clients/web/src/components/TokenLoginScreen.tsx diff --git a/web/src/components/ToolResults.tsx b/clients/web/src/components/ToolResults.tsx similarity index 100% rename from web/src/components/ToolResults.tsx rename to clients/web/src/components/ToolResults.tsx diff --git a/web/src/components/ToolsTab.tsx b/clients/web/src/components/ToolsTab.tsx similarity index 100% rename from web/src/components/ToolsTab.tsx rename to clients/web/src/components/ToolsTab.tsx diff --git a/web/src/components/__tests__/AppRenderer.test.tsx b/clients/web/src/components/__tests__/AppRenderer.test.tsx similarity index 100% rename from web/src/components/__tests__/AppRenderer.test.tsx rename to clients/web/src/components/__tests__/AppRenderer.test.tsx diff --git a/web/src/components/__tests__/AppsTab.test.tsx b/clients/web/src/components/__tests__/AppsTab.test.tsx similarity index 100% rename from web/src/components/__tests__/AppsTab.test.tsx rename to clients/web/src/components/__tests__/AppsTab.test.tsx diff --git a/web/src/components/__tests__/AuthDebugger.test.tsx b/clients/web/src/components/__tests__/AuthDebugger.test.tsx similarity index 100% rename from web/src/components/__tests__/AuthDebugger.test.tsx rename to clients/web/src/components/__tests__/AuthDebugger.test.tsx diff --git a/web/src/components/__tests__/DynamicJsonForm.array.test.tsx b/clients/web/src/components/__tests__/DynamicJsonForm.array.test.tsx similarity index 100% rename from web/src/components/__tests__/DynamicJsonForm.array.test.tsx rename to clients/web/src/components/__tests__/DynamicJsonForm.array.test.tsx diff --git a/web/src/components/__tests__/DynamicJsonForm.test.tsx b/clients/web/src/components/__tests__/DynamicJsonForm.test.tsx similarity index 100% rename from web/src/components/__tests__/DynamicJsonForm.test.tsx rename to clients/web/src/components/__tests__/DynamicJsonForm.test.tsx diff --git a/web/src/components/__tests__/ElicitationRequest.test.tsx b/clients/web/src/components/__tests__/ElicitationRequest.test.tsx similarity index 100% rename from web/src/components/__tests__/ElicitationRequest.test.tsx rename to clients/web/src/components/__tests__/ElicitationRequest.test.tsx diff --git a/web/src/components/__tests__/ElicitationTab.test.tsx b/clients/web/src/components/__tests__/ElicitationTab.test.tsx similarity index 100% rename from web/src/components/__tests__/ElicitationTab.test.tsx rename to clients/web/src/components/__tests__/ElicitationTab.test.tsx diff --git a/web/src/components/__tests__/ElicitationUrlRequest.test.tsx b/clients/web/src/components/__tests__/ElicitationUrlRequest.test.tsx similarity index 100% rename from web/src/components/__tests__/ElicitationUrlRequest.test.tsx rename to clients/web/src/components/__tests__/ElicitationUrlRequest.test.tsx diff --git a/web/src/components/__tests__/HistoryAndNotifications.test.tsx b/clients/web/src/components/__tests__/HistoryAndNotifications.test.tsx similarity index 100% rename from web/src/components/__tests__/HistoryAndNotifications.test.tsx rename to clients/web/src/components/__tests__/HistoryAndNotifications.test.tsx diff --git a/web/src/components/__tests__/ListPane.test.tsx b/clients/web/src/components/__tests__/ListPane.test.tsx similarity index 100% rename from web/src/components/__tests__/ListPane.test.tsx rename to clients/web/src/components/__tests__/ListPane.test.tsx diff --git a/web/src/components/__tests__/MetadataTab.test.tsx b/clients/web/src/components/__tests__/MetadataTab.test.tsx similarity index 100% rename from web/src/components/__tests__/MetadataTab.test.tsx rename to clients/web/src/components/__tests__/MetadataTab.test.tsx diff --git a/web/src/components/__tests__/ResourcesTab.test.tsx b/clients/web/src/components/__tests__/ResourcesTab.test.tsx similarity index 100% rename from web/src/components/__tests__/ResourcesTab.test.tsx rename to clients/web/src/components/__tests__/ResourcesTab.test.tsx diff --git a/web/src/components/__tests__/Sidebar.test.tsx b/clients/web/src/components/__tests__/Sidebar.test.tsx similarity index 100% rename from web/src/components/__tests__/Sidebar.test.tsx rename to clients/web/src/components/__tests__/Sidebar.test.tsx diff --git a/web/src/components/__tests__/ToolsTab.test.tsx b/clients/web/src/components/__tests__/ToolsTab.test.tsx similarity index 100% rename from web/src/components/__tests__/ToolsTab.test.tsx rename to clients/web/src/components/__tests__/ToolsTab.test.tsx diff --git a/web/src/components/__tests__/samplingRequest.test.tsx b/clients/web/src/components/__tests__/samplingRequest.test.tsx similarity index 100% rename from web/src/components/__tests__/samplingRequest.test.tsx rename to clients/web/src/components/__tests__/samplingRequest.test.tsx diff --git a/web/src/components/__tests__/samplingTab.test.tsx b/clients/web/src/components/__tests__/samplingTab.test.tsx similarity index 100% rename from web/src/components/__tests__/samplingTab.test.tsx rename to clients/web/src/components/__tests__/samplingTab.test.tsx diff --git a/web/src/components/ui/alert.tsx b/clients/web/src/components/ui/alert.tsx similarity index 100% rename from web/src/components/ui/alert.tsx rename to clients/web/src/components/ui/alert.tsx diff --git a/web/src/components/ui/button.tsx b/clients/web/src/components/ui/button.tsx similarity index 100% rename from web/src/components/ui/button.tsx rename to clients/web/src/components/ui/button.tsx diff --git a/web/src/components/ui/checkbox.tsx b/clients/web/src/components/ui/checkbox.tsx similarity index 100% rename from web/src/components/ui/checkbox.tsx rename to clients/web/src/components/ui/checkbox.tsx diff --git a/web/src/components/ui/combobox.tsx b/clients/web/src/components/ui/combobox.tsx similarity index 100% rename from web/src/components/ui/combobox.tsx rename to clients/web/src/components/ui/combobox.tsx diff --git a/web/src/components/ui/command.tsx b/clients/web/src/components/ui/command.tsx similarity index 100% rename from web/src/components/ui/command.tsx rename to clients/web/src/components/ui/command.tsx diff --git a/web/src/components/ui/dialog.tsx b/clients/web/src/components/ui/dialog.tsx similarity index 100% rename from web/src/components/ui/dialog.tsx rename to clients/web/src/components/ui/dialog.tsx diff --git a/web/src/components/ui/input.tsx b/clients/web/src/components/ui/input.tsx similarity index 100% rename from web/src/components/ui/input.tsx rename to clients/web/src/components/ui/input.tsx diff --git a/web/src/components/ui/label.tsx b/clients/web/src/components/ui/label.tsx similarity index 100% rename from web/src/components/ui/label.tsx rename to clients/web/src/components/ui/label.tsx diff --git a/web/src/components/ui/popover.tsx b/clients/web/src/components/ui/popover.tsx similarity index 100% rename from web/src/components/ui/popover.tsx rename to clients/web/src/components/ui/popover.tsx diff --git a/web/src/components/ui/select.tsx b/clients/web/src/components/ui/select.tsx similarity index 100% rename from web/src/components/ui/select.tsx rename to clients/web/src/components/ui/select.tsx diff --git a/web/src/components/ui/switch.tsx b/clients/web/src/components/ui/switch.tsx similarity index 100% rename from web/src/components/ui/switch.tsx rename to clients/web/src/components/ui/switch.tsx diff --git a/web/src/components/ui/tabs.tsx b/clients/web/src/components/ui/tabs.tsx similarity index 100% rename from web/src/components/ui/tabs.tsx rename to clients/web/src/components/ui/tabs.tsx diff --git a/web/src/components/ui/textarea.tsx b/clients/web/src/components/ui/textarea.tsx similarity index 100% rename from web/src/components/ui/textarea.tsx rename to clients/web/src/components/ui/textarea.tsx diff --git a/web/src/components/ui/toast.tsx b/clients/web/src/components/ui/toast.tsx similarity index 100% rename from web/src/components/ui/toast.tsx rename to clients/web/src/components/ui/toast.tsx diff --git a/web/src/components/ui/toaster.tsx b/clients/web/src/components/ui/toaster.tsx similarity index 100% rename from web/src/components/ui/toaster.tsx rename to clients/web/src/components/ui/toaster.tsx diff --git a/web/src/components/ui/tooltip.tsx b/clients/web/src/components/ui/tooltip.tsx similarity index 100% rename from web/src/components/ui/tooltip.tsx rename to clients/web/src/components/ui/tooltip.tsx diff --git a/web/src/index.css b/clients/web/src/index.css similarity index 100% rename from web/src/index.css rename to clients/web/src/index.css diff --git a/clients/web/src/index.ts b/clients/web/src/index.ts new file mode 100644 index 000000000..39068c8ed --- /dev/null +++ b/clients/web/src/index.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { runWeb } from "./web.js"; + +export { runWeb }; + +const __filename = fileURLToPath(import.meta.url); +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(__filename); + +if (isMain) { + runWeb(process.argv) + .then((code) => process.exit(code ?? 0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/web/src/lib/adapters/configAdapter.ts b/clients/web/src/lib/adapters/configAdapter.ts similarity index 100% rename from web/src/lib/adapters/configAdapter.ts rename to clients/web/src/lib/adapters/configAdapter.ts diff --git a/web/src/lib/adapters/environmentFactory.ts b/clients/web/src/lib/adapters/environmentFactory.ts similarity index 100% rename from web/src/lib/adapters/environmentFactory.ts rename to clients/web/src/lib/adapters/environmentFactory.ts diff --git a/web/src/lib/configurationTypes.ts b/clients/web/src/lib/configurationTypes.ts similarity index 100% rename from web/src/lib/configurationTypes.ts rename to clients/web/src/lib/configurationTypes.ts diff --git a/web/src/lib/constants.ts b/clients/web/src/lib/constants.ts similarity index 100% rename from web/src/lib/constants.ts rename to clients/web/src/lib/constants.ts diff --git a/web/src/lib/hooks/useCompletionState.ts b/clients/web/src/lib/hooks/useCompletionState.ts similarity index 100% rename from web/src/lib/hooks/useCompletionState.ts rename to clients/web/src/lib/hooks/useCompletionState.ts diff --git a/web/src/lib/hooks/useCopy.ts b/clients/web/src/lib/hooks/useCopy.ts similarity index 100% rename from web/src/lib/hooks/useCopy.ts rename to clients/web/src/lib/hooks/useCopy.ts diff --git a/web/src/lib/hooks/useDraggablePane.ts b/clients/web/src/lib/hooks/useDraggablePane.ts similarity index 100% rename from web/src/lib/hooks/useDraggablePane.ts rename to clients/web/src/lib/hooks/useDraggablePane.ts diff --git a/web/src/lib/hooks/useTheme.ts b/clients/web/src/lib/hooks/useTheme.ts similarity index 100% rename from web/src/lib/hooks/useTheme.ts rename to clients/web/src/lib/hooks/useTheme.ts diff --git a/web/src/lib/hooks/useToast.ts b/clients/web/src/lib/hooks/useToast.ts similarity index 100% rename from web/src/lib/hooks/useToast.ts rename to clients/web/src/lib/hooks/useToast.ts diff --git a/web/src/lib/notificationTypes.ts b/clients/web/src/lib/notificationTypes.ts similarity index 100% rename from web/src/lib/notificationTypes.ts rename to clients/web/src/lib/notificationTypes.ts diff --git a/web/src/lib/types/customHeaders.ts b/clients/web/src/lib/types/customHeaders.ts similarity index 100% rename from web/src/lib/types/customHeaders.ts rename to clients/web/src/lib/types/customHeaders.ts diff --git a/web/src/lib/utils.ts b/clients/web/src/lib/utils.ts similarity index 100% rename from web/src/lib/utils.ts rename to clients/web/src/lib/utils.ts diff --git a/web/src/main.tsx b/clients/web/src/main.tsx similarity index 100% rename from web/src/main.tsx rename to clients/web/src/main.tsx diff --git a/web/src/sandbox-controller.test.ts b/clients/web/src/sandbox-controller.test.ts similarity index 94% rename from web/src/sandbox-controller.test.ts rename to clients/web/src/sandbox-controller.test.ts index 3d7cc706a..29c1e8838 100644 --- a/web/src/sandbox-controller.test.ts +++ b/clients/web/src/sandbox-controller.test.ts @@ -55,12 +55,9 @@ describe("resolveSandboxPort", () => { }); describe("createSandboxController", () => { - const minimalHtml = "ok"; - it("start() returns port and url, getUrl() returns URL until close", async () => { const controller = createSandboxController({ port: 0, - sandboxHtml: minimalHtml, host: "127.0.0.1", }); const { port, url } = await controller.start(); @@ -74,7 +71,6 @@ describe("createSandboxController", () => { it("with port 0 (dynamic): start() uses OS-assigned port", async () => { const controller = createSandboxController({ port: 0, - sandboxHtml: minimalHtml, }); const { port, url } = await controller.start(); expect(port).toBeGreaterThan(0); @@ -85,7 +81,6 @@ describe("createSandboxController", () => { it("close() is idempotent", async () => { const controller = createSandboxController({ port: 0, - sandboxHtml: minimalHtml, }); await controller.start(); await controller.close(); diff --git a/web/src/sandbox-controller.ts b/clients/web/src/sandbox-controller.ts similarity index 84% rename from web/src/sandbox-controller.ts rename to clients/web/src/sandbox-controller.ts index a8c93d78b..cc0116a8f 100644 --- a/web/src/sandbox-controller.ts +++ b/clients/web/src/sandbox-controller.ts @@ -4,12 +4,15 @@ */ import { createServer, type Server } from "node:http"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export interface SandboxControllerOptions { /** Port to bind (0 = dynamic). */ port: number; - /** HTML content to serve for GET /sandbox and /sandbox/ */ - sandboxHtml: string; /** Host to bind (default localhost). */ host?: string; } @@ -40,10 +43,21 @@ export function resolveSandboxPort(): number { export function createSandboxController( options: SandboxControllerOptions, ): SandboxController { - const { port, sandboxHtml, host = "localhost" } = options; + const { port, host = "localhost" } = options; let server: Server | null = null; let sandboxUrl: string | null = null; + let sandboxHtml: string; + try { + const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); + sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); + } catch (e) { + sandboxHtml = + "Sandbox not loaded: " + + String((e as Error).message) + + ""; + } + return { async start(): Promise<{ port: number; url: string }> { if (server && sandboxUrl) { diff --git a/clients/web/src/server.ts b/clients/web/src/server.ts new file mode 100644 index 000000000..ab64dcc08 --- /dev/null +++ b/clients/web/src/server.ts @@ -0,0 +1,148 @@ +/** + * Hono production server. Export startHonoServer(config) for in-process use by the runner. + * When run as the main module (e.g. node dist/server.js), build config from env and start. + */ + +import { readFileSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import open from "open"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { createSandboxController } from "./sandbox-controller.js"; +import type { WebServerConfig } from "./web-server-config.js"; +import { + webServerConfigToInitialPayload, + buildWebServerConfigFromEnv, + printServerBanner, +} from "./web-server-config.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export interface WebServerHandle { + close(): Promise; +} + +/** + * Start the Hono production server in-process. Returns a handle that closes sandbox then HTTP server. + * Caller owns SIGINT/SIGTERM; do not register signal handlers here. + */ +export async function startHonoServer( + config: WebServerConfig, +): Promise { + const sandboxController = createSandboxController({ + port: config.sandboxPort, + host: config.sandboxHost, + }); + await sandboxController.start(); + + const resolvedAuthToken = + config.authToken || + (config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex")); + + const rootPath = config.staticRoot ?? __dirname; + + const { app: apiApp } = createRemoteApp({ + authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken, + dangerouslyOmitAuth: config.dangerouslyOmitAuth, + storageDir: config.storageDir, + allowedOrigins: config.allowedOrigins, + sandboxUrl: sandboxController.getUrl() ?? undefined, + logger: config.logger, + initialConfig: webServerConfigToInitialPayload(config), + }); + + const app = new Hono(); + app.use("/api/*", async (c) => { + return apiApp.fetch(c.req.raw); + }); + + app.get("/", async (c) => { + try { + const indexPath = join(rootPath, "index.html"); + const html = readFileSync(indexPath, "utf-8"); + return c.html(html); + } catch (error) { + console.error("Error serving index.html:", error); + return c.notFound(); + } + }); + + app.use( + "/*", + serveStatic({ + root: rootPath, + rewriteRequestPath: (path) => { + if (!path.includes(".") && !path.startsWith("/api")) { + return "/index.html"; + } + return path; + }, + }), + ); + + const httpServer = serve( + { + fetch: app.fetch, + port: config.port, + hostname: config.hostname, + }, + (info) => { + const sandboxUrl = sandboxController.getUrl(); + const url = printServerBanner( + config, + info.port, + resolvedAuthToken, + sandboxUrl ?? undefined, + ); + if (config.autoOpen) { + open(url); + } + }, + ); + + httpServer.on("error", (err: Error) => { + if (err.message.includes("EADDRINUSE")) { + console.error( + `āŒ MCP Inspector PORT IS IN USE at http://${config.hostname}:${config.port} āŒ `, + ); + process.exit(1); + } else { + throw err; + } + }); + + return { + async close(): Promise { + await sandboxController.close(); + if ("closeAllConnections" in httpServer) { + httpServer.closeAllConnections(); + } + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +/** Run when this file is executed as the main module (e.g. node dist/server.js). */ +async function runStandalone(): Promise { + const config = buildWebServerConfigFromEnv(); + const handle = await startHonoServer(config); + const shutdown = () => { + void handle.close().then(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +const isMain = + process.argv[1] !== undefined && + resolve(process.argv[1]) === resolve(__filename); +if (isMain) { + void runStandalone(); +} diff --git a/clients/web/src/start-vite-dev-server.ts b/clients/web/src/start-vite-dev-server.ts new file mode 100644 index 000000000..1f330a97f --- /dev/null +++ b/clients/web/src/start-vite-dev-server.ts @@ -0,0 +1,48 @@ +/** + * Start Vite dev server in-process via the Node API. Config is passed into the plugin; no shared state. + */ + +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer, type InlineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import type { WebServerConfig } from "./web-server-config.js"; +import { honoMiddlewarePlugin } from "./vite-hono-plugin.js"; +import { getViteBaseConfig } from "./vite-base-config.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface WebServerHandle { + close(): Promise; +} + +/** + * Start the Vite dev server in-process. Passes config into the plugin. Caller owns SIGINT/SIGTERM. + * Uses shared base config (no loading vite.config.ts — Node can't import .ts). + */ +export async function startViteDevServer( + config: WebServerConfig, +): Promise { + // Resolve to a canonical path so Vite's config hash is stable and matches the deps cache. + const root = resolve(join(__dirname, "..")); + const baseConfig = getViteBaseConfig(root); + const inlineConfig: InlineConfig = { + ...baseConfig, + configFile: false, + root, + server: { + port: config.port, + host: config.hostname, + }, + plugins: [react(), honoMiddlewarePlugin(config)], + }; + const server = await createServer(inlineConfig); + + await server.listen(); + + return { + async close(): Promise { + await server.close(); + }, + }; +} diff --git a/web/src/test-setup.ts b/clients/web/src/test-setup.ts similarity index 100% rename from web/src/test-setup.ts rename to clients/web/src/test-setup.ts diff --git a/web/src/utils/__tests__/configUtils.test.ts b/clients/web/src/utils/__tests__/configUtils.test.ts similarity index 100% rename from web/src/utils/__tests__/configUtils.test.ts rename to clients/web/src/utils/__tests__/configUtils.test.ts diff --git a/web/src/utils/__tests__/escapeUnicode.test.ts b/clients/web/src/utils/__tests__/escapeUnicode.test.ts similarity index 100% rename from web/src/utils/__tests__/escapeUnicode.test.ts rename to clients/web/src/utils/__tests__/escapeUnicode.test.ts diff --git a/web/src/utils/__tests__/jsonUtils.test.ts b/clients/web/src/utils/__tests__/jsonUtils.test.ts similarity index 100% rename from web/src/utils/__tests__/jsonUtils.test.ts rename to clients/web/src/utils/__tests__/jsonUtils.test.ts diff --git a/web/src/utils/__tests__/oauthUtils.test.ts b/clients/web/src/utils/__tests__/oauthUtils.test.ts similarity index 100% rename from web/src/utils/__tests__/oauthUtils.test.ts rename to clients/web/src/utils/__tests__/oauthUtils.test.ts diff --git a/web/src/utils/__tests__/paramUtils.test.ts b/clients/web/src/utils/__tests__/paramUtils.test.ts similarity index 100% rename from web/src/utils/__tests__/paramUtils.test.ts rename to clients/web/src/utils/__tests__/paramUtils.test.ts diff --git a/web/src/utils/__tests__/schemaUtils.test.ts b/clients/web/src/utils/__tests__/schemaUtils.test.ts similarity index 100% rename from web/src/utils/__tests__/schemaUtils.test.ts rename to clients/web/src/utils/__tests__/schemaUtils.test.ts diff --git a/web/src/utils/__tests__/urlValidation.test.ts b/clients/web/src/utils/__tests__/urlValidation.test.ts similarity index 100% rename from web/src/utils/__tests__/urlValidation.test.ts rename to clients/web/src/utils/__tests__/urlValidation.test.ts diff --git a/web/src/utils/configUtils.ts b/clients/web/src/utils/configUtils.ts similarity index 100% rename from web/src/utils/configUtils.ts rename to clients/web/src/utils/configUtils.ts diff --git a/web/src/utils/escapeUnicode.ts b/clients/web/src/utils/escapeUnicode.ts similarity index 100% rename from web/src/utils/escapeUnicode.ts rename to clients/web/src/utils/escapeUnicode.ts diff --git a/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts similarity index 100% rename from web/src/utils/jsonUtils.ts rename to clients/web/src/utils/jsonUtils.ts diff --git a/web/src/utils/metaUtils.ts b/clients/web/src/utils/metaUtils.ts similarity index 100% rename from web/src/utils/metaUtils.ts rename to clients/web/src/utils/metaUtils.ts diff --git a/web/src/utils/oauthUtils.ts b/clients/web/src/utils/oauthUtils.ts similarity index 100% rename from web/src/utils/oauthUtils.ts rename to clients/web/src/utils/oauthUtils.ts diff --git a/web/src/utils/paramUtils.ts b/clients/web/src/utils/paramUtils.ts similarity index 100% rename from web/src/utils/paramUtils.ts rename to clients/web/src/utils/paramUtils.ts diff --git a/web/src/utils/schemaUtils.ts b/clients/web/src/utils/schemaUtils.ts similarity index 100% rename from web/src/utils/schemaUtils.ts rename to clients/web/src/utils/schemaUtils.ts diff --git a/web/src/utils/urlValidation.ts b/clients/web/src/utils/urlValidation.ts similarity index 100% rename from web/src/utils/urlValidation.ts rename to clients/web/src/utils/urlValidation.ts diff --git a/clients/web/src/vite-base-config.ts b/clients/web/src/vite-base-config.ts new file mode 100644 index 000000000..0a22616ae --- /dev/null +++ b/clients/web/src/vite-base-config.ts @@ -0,0 +1,40 @@ +/** + * Shared Vite config (resolve, build, optimizeDeps). Used by vite.config.ts and start-vite-dev-server.ts + * so the runner can build a full config without loading vite.config.ts (Node can't import .ts). + */ + +import path from "path"; + +export function getViteBaseConfig(root: string) { + const resolvedRoot = path.resolve(root); + return { + resolve: { + alias: { + "@": path.join(resolvedRoot, "src"), + }, + conditions: ["browser", "module", "import"], + }, + build: { + minify: false, + rollupOptions: { + output: { + manualChunks: undefined, + }, + external: [ + "@modelcontextprotocol/sdk/client/stdio.js", + "cross-spawn", + "which", + ], + }, + }, + optimizeDeps: { + exclude: [ + "@modelcontextprotocol/sdk/client/stdio.js", + "@modelcontextprotocol/inspector-core/mcp/node", + "@modelcontextprotocol/inspector-core/mcp/remote/node", + "cross-spawn", + "which", + ], + }, + }; +} diff --git a/web/src/vite-env.d.ts b/clients/web/src/vite-env.d.ts similarity index 100% rename from web/src/vite-env.d.ts rename to clients/web/src/vite-env.d.ts diff --git a/clients/web/src/vite-hono-plugin.ts b/clients/web/src/vite-hono-plugin.ts new file mode 100644 index 000000000..7081a0fda --- /dev/null +++ b/clients/web/src/vite-hono-plugin.ts @@ -0,0 +1,158 @@ +/** + * Vite plugin that adds Hono middleware for /api/* and the MCP Apps sandbox. + * Receives WebServerConfig only (from runner or from buildWebServerConfigFromEnv in vite.config). + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin } from "vite"; +import open from "open"; +import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { createSandboxController } from "./sandbox-controller.js"; +import type { WebServerConfig } from "./web-server-config.js"; +import { + webServerConfigToInitialPayload, + printServerBanner, +} from "./web-server-config.js"; + +/** + * Plugin factory. Caller must pass a WebServerConfig (runner builds it from argv; vite.config builds it from env via buildWebServerConfigFromEnv). + */ +export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { + return { + name: "hono-api-middleware", + async configureServer(server) { + const sandboxController = createSandboxController({ + port: config.sandboxPort, + host: config.sandboxHost, + }); + await sandboxController.start(); + + if (!server.httpServer) { + throw new Error( + "Vite HTTP server is not available. This plugin requires a running HTTP server (middleware mode is not supported).", + ); + } + + const originalClose = server.close.bind(server); + server.close = async () => { + await sandboxController.close(); + return originalClose(); + }; + + const { app: honoApp, authToken: resolvedToken } = createRemoteApp({ + authToken: config.dangerouslyOmitAuth ? undefined : config.authToken, + dangerouslyOmitAuth: config.dangerouslyOmitAuth, + storageDir: config.storageDir, + allowedOrigins: config.allowedOrigins, + sandboxUrl: sandboxController.getUrl() ?? undefined, + logger: config.logger, + initialConfig: webServerConfigToInitialPayload(config), + }); + + const sandboxUrl = sandboxController.getUrl(); + + const logBanner = () => { + const address = server.httpServer?.address(); + const actualPort = + typeof address === "object" && address !== null + ? address.port + : config.port; + + const url = printServerBanner( + config, + actualPort, + resolvedToken, + sandboxUrl ?? undefined, + ); + + if (config.autoOpen) { + open(url); + } + }; + + server.httpServer.once("listening", () => { + setImmediate(logBanner); + }); + + const honoMiddleware = async ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) => { + try { + const pathname = req.url || ""; + if (!pathname.startsWith("/api")) { + return next(); + } + const url = `http://${req.headers.host}${pathname}`; + const headers = new Headers(); + Object.entries(req.headers).forEach(([key, value]) => { + if (value) { + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + }); + const init: RequestInit = { method: req.method, headers }; + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => { + req.on("end", () => resolve()); + }); + if (chunks.length > 0) { + init.body = Buffer.concat(chunks); + } + } + const response = await honoApp.fetch(new Request(url, init)); + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + const isSSE = response.headers + .get("content-type") + ?.includes("text/event-stream"); + if (isSSE) { + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + } + if (response.body) { + res.flushHeaders?.(); + const reader = response.body.getReader(); + const pump = async () => { + try { + const { done, value } = await reader.read(); + if (done) { + res.end(); + } else { + res.write(Buffer.from(value), (err) => { + if (err) { + console.error("[Hono Middleware] Write error:", err); + reader.cancel().catch(() => {}); + res.end(); + } + }); + pump().catch((err) => { + console.error("[Hono Middleware] Pump error:", err); + reader.cancel().catch(() => {}); + res.end(); + }); + } + } catch (err) { + console.error("[Hono Middleware] Read error:", err); + reader.cancel().catch(() => {}); + res.end(); + } + }; + pump(); + } else { + res.end(); + } + } catch (error) { + next(error); + } + }; + + server.middlewares.use(honoMiddleware); + }, + }; +} diff --git a/clients/web/src/web-server-config.ts b/clients/web/src/web-server-config.ts new file mode 100644 index 000000000..e02a3aafe --- /dev/null +++ b/clients/web/src/web-server-config.ts @@ -0,0 +1,194 @@ +/** + * Config object for the web server (dev and prod). Passed in-process; no env handoff. + */ + +import pino from "pino"; +import type { Logger } from "pino"; +import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; +import type { InitialConfigPayload } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; +import { resolveSandboxPort } from "./sandbox-controller.js"; + +export interface WebServerConfig { + port: number; + hostname: string; + authToken: string; + dangerouslyOmitAuth: boolean; + /** Single initial MCP server config, or null when no server specified. */ + initialMcpConfig: MCPServerConfig | null; + storageDir: string | undefined; + allowedOrigins: string[]; + /** Sandbox port (0 = dynamic). */ + sandboxPort: number; + sandboxHost: string; + logger: Logger | undefined; + /** When true, open browser after server starts. */ + autoOpen: boolean; + /** Root directory for static files (index.html, assets). When runner starts server in-process, pass path to dist/. */ + staticRoot?: string; +} + +/** + * Build defaultEnvironment for InitialConfigPayload (platform env keys + optional extra). + */ +function defaultEnvironmentFromProcess( + extra?: Record, +): Record { + const keys = + process.platform === "win32" + ? [ + "APPDATA", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "PATH", + "PROCESSOR_ARCHITECTURE", + "SYSTEMDRIVE", + "SYSTEMROOT", + "TEMP", + "USERNAME", + "USERPROFILE", + "PROGRAMFILES", + ] + : ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; + + const out: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (value && !value.startsWith("()")) { + out[key] = value; + } + } + if (extra) { + Object.assign(out, extra); + } + return out; +} + +/** + * Convert WebServerConfig.initialMcpConfig to the shape expected by GET /api/config. + */ +export function webServerConfigToInitialPayload( + config: WebServerConfig, +): InitialConfigPayload { + const mc = config.initialMcpConfig; + const defaultEnvironment = defaultEnvironmentFromProcess( + mc && "env" in mc && mc.env ? mc.env : undefined, + ); + + if (!mc) { + return { defaultEnvironment }; + } + + if (mc.type === "stdio" || mc.type === undefined) { + return { + defaultCommand: mc.command, + defaultArgs: mc.args ?? [], + defaultTransport: "stdio", + defaultCwd: mc.cwd, + defaultEnvironment, + }; + } + if (mc.type === "sse") { + return { + defaultTransport: "sse", + defaultServerUrl: mc.url, + defaultHeaders: mc.headers ?? undefined, + defaultEnvironment, + }; + } + if (mc.type === "streamable-http") { + return { + defaultTransport: "streamable-http", + defaultServerUrl: mc.url, + defaultHeaders: mc.headers ?? undefined, + defaultEnvironment, + }; + } + const c = mc as unknown as { url: string; headers?: Record }; + return { + defaultTransport: "streamable-http", + defaultServerUrl: c.url, + defaultHeaders: c.headers, + defaultEnvironment, + }; +} + +export function printServerBanner( + config: WebServerConfig, + actualPort: number, + resolvedToken: string, + sandboxUrl: string | undefined, +): string { + const baseUrl = `http://${config.hostname}:${actualPort}`; + const url = + config.dangerouslyOmitAuth || !resolvedToken + ? baseUrl + : `${baseUrl}?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${resolvedToken}`; + + console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); + if (sandboxUrl) { + console.log(` Sandbox (MCP Apps): ${sandboxUrl}\n`); + } + if (config.dangerouslyOmitAuth) { + console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n"); + } else { + console.log(` Auth token: ${resolvedToken}\n`); + } + + if (config.autoOpen) { + console.log("🌐 Opening browser..."); + } + + return url; +} + +/** + * Build WebServerConfig from process.env. Used when running server as standalone (e.g. node dist/server.js). + */ +export function buildWebServerConfigFromEnv(): WebServerConfig { + const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); + const hostname = process.env.HOST ?? "localhost"; + const baseUrl = `http://${hostname}:${port}`; + const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; + const authToken = dangerouslyOmitAuth + ? "" + : ((process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] as string | undefined) ?? + (process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ?? + ""); + + const initialMcpConfig: MCPServerConfig | null = null; + + const sandboxPort = resolveSandboxPort(); + + let logger: Logger | undefined; + if (process.env.MCP_LOG_FILE) { + logger = pino( + { level: "info" }, + pino.destination({ + dest: process.env.MCP_LOG_FILE, + append: true, + mkdir: true, + }), + ); + } + + return { + port, + hostname, + authToken, + dangerouslyOmitAuth, + initialMcpConfig, + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [ + baseUrl, + ], + sandboxPort, + sandboxHost: hostname, + logger, + autoOpen: process.env.MCP_AUTO_OPEN_ENABLED !== "false", + }; +} diff --git a/clients/web/src/web.ts b/clients/web/src/web.ts new file mode 100644 index 000000000..5865d58a6 --- /dev/null +++ b/clients/web/src/web.ts @@ -0,0 +1,295 @@ +import { resolve, join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { randomBytes } from "crypto"; +import { Command } from "commander"; +import type { + MCPServerConfig, + StreamableHttpServerConfig, +} from "@modelcontextprotocol/inspector-core/mcp/types.js"; +import { + API_SERVER_ENV_VARS, + LEGACY_AUTH_TOKEN_ENV, +} from "@modelcontextprotocol/inspector-core/mcp/remote"; +import { + resolveServerConfigs, + parseKeyValuePair, + parseHeaderPair, + type ServerConfigOptions, +} from "@modelcontextprotocol/inspector-core/mcp/node/config.js"; +import { resolveSandboxPort } from "./sandbox-controller.js"; +import type { WebServerConfig } from "./web-server-config.js"; +import { startViteDevServer } from "./start-vite-dev-server.js"; +import { startHonoServer } from "./server.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface WebClientOptions { + command: string | null; + mcpServerArgs: string[]; + transport: string | null; + serverUrl: string | null; + headers: Record | null; + envVars: Record; + cwd: string | null; + isDev: boolean; +} + +function mcpConfigToWebClientOptions( + config: MCPServerConfig, + isDev: boolean, +): WebClientOptions { + if (config.type === "stdio") { + return { + command: config.command ?? null, + mcpServerArgs: config.args ?? [], + transport: "stdio", + serverUrl: null, + headers: null, + envVars: config.env ?? {}, + cwd: config.cwd ?? null, + isDev, + }; + } + if (config.type === "sse") { + return { + command: null, + mcpServerArgs: [], + transport: "sse", + serverUrl: config.url, + headers: config.headers ?? null, + envVars: {}, + cwd: null, + isDev, + }; + } + if (config.type === "streamable-http") { + return { + command: null, + mcpServerArgs: [], + transport: "streamable-http", + serverUrl: config.url, + headers: config.headers ?? null, + envVars: {}, + cwd: null, + isDev, + }; + } + const c = config as unknown as StreamableHttpServerConfig; + return { + command: null, + mcpServerArgs: [], + transport: "streamable-http", + serverUrl: c.url, + headers: c.headers ?? null, + envVars: {}, + cwd: null, + isDev, + }; +} + +function buildWebServerConfig( + clientOptions: WebClientOptions, + port: number, + hostname: string, + authToken: string, + dangerouslyOmitAuth: boolean, +): WebServerConfig { + const baseUrl = `http://${hostname}:${port}`; + const initialMcpConfig: MCPServerConfig | null = + clientOptions.command != null || clientOptions.serverUrl != null + ? clientOptions.transport === "stdio" + ? { + type: "stdio", + command: clientOptions.command ?? "", + args: + clientOptions.mcpServerArgs.length > 0 + ? clientOptions.mcpServerArgs + : undefined, + cwd: clientOptions.cwd ?? undefined, + env: + Object.keys(clientOptions.envVars).length > 0 + ? clientOptions.envVars + : undefined, + } + : clientOptions.transport === "sse" + ? { + type: "sse", + url: clientOptions.serverUrl ?? "", + headers: clientOptions.headers ?? undefined, + } + : { + type: "streamable-http", + url: clientOptions.serverUrl ?? "", + headers: clientOptions.headers ?? undefined, + } + : null; + + return { + port, + hostname, + authToken, + dangerouslyOmitAuth, + initialMcpConfig, + storageDir: process.env.MCP_STORAGE_DIR, + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",").filter(Boolean) ?? [ + baseUrl, + ], + sandboxPort: resolveSandboxPort(), + sandboxHost: hostname, + logger: undefined, + autoOpen: process.env.MCP_AUTO_OPEN_ENABLED !== "false", + }; +} + +export async function runWeb(argv: string[]): Promise { + const program = new Command(); + + const argSeparatorIndex = argv.indexOf("--"); + let preArgs = argv; + let postArgs: string[] = []; + if (argSeparatorIndex !== -1) { + preArgs = argv.slice(0, argSeparatorIndex); + postArgs = argv.slice(argSeparatorIndex + 1); + } + + program + .name("mcp-inspector-web") + .description("Web UI for MCP Inspector") + .allowExcessArguments() + .allowUnknownOption() + .option( + "-e ", + "environment variables in KEY=VALUE format", + parseKeyValuePair, + {}, + ) + .option("--config ", "config file path") + .option("--server ", "server name from config file") + .option("--transport ", "transport type (stdio, sse, http)") + .option("--server-url ", "server URL for SSE/HTTP transport") + .option("--cwd ", "working directory for stdio server process") + .option( + "--header ", + 'HTTP headers as "HeaderName: Value" pairs (for HTTP/SSE transports)', + parseHeaderPair, + {}, + ) + .option("--dev", "run in development mode (Vite)") + .parse(preArgs); + + const opts = program.opts() as { + config?: string; + server?: string; + e?: Record; + transport?: string; + serverUrl?: string; + cwd?: string; + header?: Record; + dev?: boolean; + }; + + const args = program.args; + const target = [...args, ...postArgs]; + + const hasServerInput = + opts.config || + target.length > 0 || + opts.serverUrl || + (opts.transport && opts.transport !== "stdio"); + + let clientOptions: WebClientOptions; + + if (!hasServerInput) { + clientOptions = { + command: null, + mcpServerArgs: [], + transport: null, + serverUrl: null, + headers: null, + envVars: opts.e ?? {}, + cwd: null, + isDev: !!opts.dev, + }; + } else { + const serverOptions: ServerConfigOptions = { + configPath: opts.config, + serverName: opts.server, + target: target.length > 0 ? target : undefined, + transport: opts.transport as "stdio" | "sse" | "http" | undefined, + serverUrl: opts.serverUrl, + cwd: opts.cwd, + env: opts.e, + headers: opts.header, + }; + + try { + const configs = resolveServerConfigs(serverOptions, "single"); + const config = configs[0]; + if (!config) { + console.error( + "Error: Could not resolve server config. Use --config and --server, or pass a command/URL.", + ); + process.exit(1); + } + clientOptions = mcpConfigToWebClientOptions(config, !!opts.dev); + if (clientOptions.command && !clientOptions.cwd) { + clientOptions.cwd = resolve(process.cwd()); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Could not resolve server config."; + console.error("Error:", message); + process.exit(1); + } + } + + const port = parseInt(process.env.CLIENT_PORT ?? "6274", 10); + const hostname = process.env.HOST ?? "localhost"; + const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; + const authToken = dangerouslyOmitAuth + ? "" + : ((process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] as string | undefined) ?? + (process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ?? + randomBytes(32).toString("hex")); + + const webConfig = buildWebServerConfig( + clientOptions, + port, + hostname, + authToken, + dangerouslyOmitAuth, + ); + if (!clientOptions.isDev) { + webConfig.staticRoot = join(__dirname, "..", "dist"); + } + + console.log( + clientOptions.isDev + ? "Starting MCP inspector in development mode..." + : "Starting MCP inspector...", + ); + + let handle: { close(): Promise }; + + try { + if (clientOptions.isDev) { + handle = await startViteDevServer(webConfig); + } else { + handle = await startHonoServer(webConfig); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Web client failed to start."; + console.error("Error:", message); + process.exit(1); + } + + const shutdown = () => { + void handle.close().then(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + // Process stays alive until SIGINT/SIGTERM (handler exits). Return a never-resolving promise. + return new Promise(() => {}); +} diff --git a/web/static/sandbox_proxy.html b/clients/web/static/sandbox_proxy.html similarity index 100% rename from web/static/sandbox_proxy.html rename to clients/web/static/sandbox_proxy.html diff --git a/web/tailwind.config.js b/clients/web/tailwind.config.js similarity index 100% rename from web/tailwind.config.js rename to clients/web/tailwind.config.js diff --git a/web/tsconfig.app.json b/clients/web/tsconfig.app.json similarity index 91% rename from web/tsconfig.app.json rename to clients/web/tsconfig.app.json index 8af24a7a6..930f6c7c5 100644 --- a/web/tsconfig.app.json +++ b/clients/web/tsconfig.app.json @@ -27,5 +27,6 @@ "resolveJsonModule": true, "types": ["vitest/globals", "@testing-library/jest-dom", "node"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/index.ts", "src/web.ts"] } diff --git a/web/tsconfig.json b/clients/web/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to clients/web/tsconfig.json diff --git a/web/tsconfig.node.json b/clients/web/tsconfig.node.json similarity index 100% rename from web/tsconfig.node.json rename to clients/web/tsconfig.node.json diff --git a/clients/web/tsconfig.runner.json b/clients/web/tsconfig.runner.json new file mode 100644 index 000000000..7bc2dffb3 --- /dev/null +++ b/clients/web/tsconfig.runner.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": false, + "noEmit": false + }, + "include": [ + "src/index.ts", + "src/web.ts", + "src/web-server-config.ts", + "src/start-vite-dev-server.ts", + "src/vite-base-config.ts", + "src/vite-hono-plugin.ts", + "src/server.ts", + "src/sandbox-controller.ts" + ] +} diff --git a/web/tsconfig.server.json b/clients/web/tsconfig.server.json similarity index 75% rename from web/tsconfig.server.json rename to clients/web/tsconfig.server.json index 251247812..bf40c763c 100644 --- a/web/tsconfig.server.json +++ b/clients/web/tsconfig.server.json @@ -12,5 +12,9 @@ "declaration": false, "noEmit": false }, - "include": ["src/server.ts"] + "include": [ + "src/server.ts", + "src/web-server-config.ts", + "src/sandbox-controller.ts" + ] } diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts new file mode 100644 index 000000000..d2cf2a6f4 --- /dev/null +++ b/clients/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { honoMiddlewarePlugin } from "./src/vite-hono-plugin.js"; +import { getViteBaseConfig } from "./src/vite-base-config.js"; +import { buildWebServerConfigFromEnv } from "./src/web-server-config.js"; + +// https://vitejs.dev/config/ +export default defineConfig({ + ...getViteBaseConfig(__dirname), + plugins: [react(), honoMiddlewarePlugin(buildWebServerConfigFromEnv())], + server: { + host: true, + }, +}); diff --git a/web/vitest.config.ts b/clients/web/vitest.config.ts similarity index 82% rename from web/vitest.config.ts rename to clients/web/vitest.config.ts index f407232ea..f35df473f 100644 --- a/web/vitest.config.ts +++ b/clients/web/vitest.config.ts @@ -5,7 +5,13 @@ import viteConfig from "./vite.config"; // Extend Vite config for Vitest (shared resolve, plugins) export default defineConfig({ ...viteConfig, - plugins: viteConfig.plugins || [], + plugins: (viteConfig.plugins || []).filter( + (plugin) => + plugin && + typeof plugin === "object" && + "name" in plugin && + plugin.name !== "hono-api-middleware", + ), resolve: { ...viteConfig.resolve, alias: { diff --git a/core/README.md b/core/README.md new file mode 100644 index 000000000..17eea6398 --- /dev/null +++ b/core/README.md @@ -0,0 +1,29 @@ +# MCP Inspector Core + +The Core package provides the shared foundation for all MCP Inspector clients (`web`, `cli`, and `tui`). It acts as the single source of truth for MCP protocol interactions, state management, and React hooks across the monorepo. + +## Architecture + +The Inspector follows a unified architecture where `InspectorClient` acts as the primary protocol layer. + +For detailed information about the shared architecture, please see the [Shared Code Architecture Document](../../docs/shared-code-architecture.md). + +### InspectorClient + +The `InspectorClient` (`src/mcp/inspectorClient.ts`) wraps the official MCP SDK `Client`. Its responsibilities include: + +- Managing the lifecycle and connection of the transport layer. +- Exposing stateless list RPCs (e.g. `listTools`, `listResources`). +- Dispatching events (e.g., `message`, `toolsListChanged`). + +It uses **environment isolation** to remain fully portable across Node.js (CLI, TUI, Dev server) and the browser (Web). Environment-specific implementations like transports (`createTransportNode` vs `createRemoteTransport`) and storage adapters are injected into it via an `InspectorClientEnvironment`. + +### State Managers + +While `InspectorClient` is stateless, list and log state are held in dedicated state managers located in `src/mcp/state/`. + +These managers (e.g. `PagedToolsState`, `MessageLogState`) subscribe to the `InspectorClient` events, request data via its RPCs, hold caches or lists, and emit their own granular change events. + +### React Integration + +For clients utilizing React (Web and TUI), the `src/react/` directory provides custom React hooks (e.g. `useInspectorClient`, `usePagedTools`, `useMessageLog`) that bind the class-based state managers to component state. diff --git a/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts b/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts index fe09ff544..8e94343e9 100644 --- a/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts +++ b/core/__tests__/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -82,6 +82,7 @@ async function startRemoteServer( }> { const { app, authToken } = createRemoteApp({ storageDir: options.storageDir, + initialConfig: { defaultEnvironment: {} }, }); return new Promise((resolve, reject) => { const server = serve( diff --git a/core/__tests__/remote-server-config.test.ts b/core/__tests__/remote-server-config.test.ts index 0a1b333a4..33f3b21f1 100644 --- a/core/__tests__/remote-server-config.test.ts +++ b/core/__tests__/remote-server-config.test.ts @@ -8,6 +8,7 @@ describe("createRemoteApp GET /api/config", () => { dangerouslyOmitAuth: true, allowedOrigins: ["http://127.0.0.1:6274"], sandboxUrl, + initialConfig: { defaultEnvironment: {} }, }); const res = await app.request(new Request("http://test/api/config")); expect(res.status).toBe(200); @@ -19,10 +20,39 @@ describe("createRemoteApp GET /api/config", () => { const { app } = createRemoteApp({ dangerouslyOmitAuth: true, allowedOrigins: ["http://127.0.0.1:6274"], + initialConfig: { defaultEnvironment: {} }, }); const res = await app.request(new Request("http://test/api/config")); expect(res.status).toBe(200); const data = (await res.json()) as { sandboxUrl?: string }; expect(data).not.toHaveProperty("sandboxUrl"); }); + + it("uses initialConfig when provided instead of env", async () => { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + allowedOrigins: ["http://127.0.0.1:6274"], + initialConfig: { + defaultCommand: "my-server", + defaultArgs: ["--foo"], + defaultTransport: "stdio", + defaultCwd: "/tmp", + defaultEnvironment: { PATH: "/usr/bin" }, + }, + }); + const res = await app.request(new Request("http://test/api/config")); + expect(res.status).toBe(200); + const data = (await res.json()) as { + defaultCommand?: string; + defaultArgs?: string[]; + defaultTransport?: string; + defaultCwd?: string; + defaultEnvironment?: Record; + }; + expect(data.defaultCommand).toBe("my-server"); + expect(data.defaultArgs).toEqual(["--foo"]); + expect(data.defaultTransport).toBe("stdio"); + expect(data.defaultCwd).toBe("/tmp"); + expect(data.defaultEnvironment).toEqual({ PATH: "/usr/bin" }); + }); }); diff --git a/core/__tests__/remote-transport.test.ts b/core/__tests__/remote-transport.test.ts index 5ca10b4a5..e2a520c9c 100644 --- a/core/__tests__/remote-transport.test.ts +++ b/core/__tests__/remote-transport.test.ts @@ -44,6 +44,7 @@ async function startRemoteServer( storageDir: options.storageDir, allowedOrigins: options.allowedOrigins, dangerouslyOmitAuth: options.dangerouslyOmitAuth, + initialConfig: { defaultEnvironment: {} }, }); return new Promise((resolve, reject) => { const server = serve( diff --git a/core/__tests__/storage-adapters.test.ts b/core/__tests__/storage-adapters.test.ts index f259383c0..df20bcf7e 100644 --- a/core/__tests__/storage-adapters.test.ts +++ b/core/__tests__/storage-adapters.test.ts @@ -28,6 +28,7 @@ async function startRemoteServer( }> { const { app, authToken } = createRemoteApp({ storageDir: options.storageDir, + initialConfig: { defaultEnvironment: {} }, }); return new Promise((resolve, reject) => { const server = serve( diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index b55ffdd93..55983a320 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -1,22 +1,111 @@ -import { readFileSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { resolve } from "path"; import type { MCPConfig, MCPServerConfig, + ServerType, StdioServerConfig, SseServerConfig, StreamableHttpServerConfig, } from "../types.js"; /** - * Loads and validates an MCP servers configuration file + * Options object passed to resolveServerConfigs by runners (parsed from argv). + * Core exports this type so runners can type the subset they pass in. + */ +export interface ServerConfigOptions { + configPath?: string; + serverName?: string; + /** Command + args for stdio, or [url] for SSE/HTTP. Positional / args after -- */ + target?: string[]; + transport?: "stdio" | "sse" | "http"; + serverUrl?: string; + cwd?: string; + env?: Record; + headers?: Record; +} + +/** + * Parse KEY=VALUE into a record. Used as Commander option coerce/accumulator for -e. + * Pure function; no Commander dependency. + */ +export function parseKeyValuePair( + value: string, + previous: Record = {}, +): Record { + const parts = value.split("="); + const key = parts[0] ?? ""; + const val = parts.slice(1).join("="); + + if (!key || val === undefined || val === "") { + throw new Error( + `Invalid parameter format: ${value}. Use key=value format.`, + ); + } + + return { ...previous, [key]: val }; +} + +/** + * Parse "HeaderName: Value" into a record. Used as Commander option coerce/accumulator for --header. + * Pure function; no Commander dependency. + */ +export function parseHeaderPair( + value: string, + previous: Record = {}, +): Record { + const colonIndex = value.indexOf(":"); + + if (colonIndex === -1) { + throw new Error( + `Invalid header format: ${value}. Use "HeaderName: Value" format.`, + ); + } + + const key = value.slice(0, colonIndex).trim(); + const val = value.slice(colonIndex + 1).trim(); + + if (key === "" || val === "") { + throw new Error( + `Invalid header format: ${value}. Use "HeaderName: Value" format.`, + ); + } + + return { ...previous, [key]: val }; +} + +/** + * Normalizes server type: missing → "stdio", "http" → "streamable-http". + * Returns a new object; input may be parsed JSON with type omitted or "http". + */ +function normalizeServerType( + config: Record & { type?: string }, +): MCPServerConfig { + const type = config.type; + const normalizedType: ServerType = + type === undefined + ? "stdio" + : type === "http" + ? "streamable-http" + : (type as ServerType); + return { ...config, type: normalizedType } as MCPServerConfig; +} + +/** + * Loads and validates an MCP servers configuration file. + * Checks file existence before reading. Normalizes each server's type + * (missing → "stdio", "http" → "streamable-http"). + * * @param configPath - Path to the config file (relative to process.cwd() or absolute) - * @returns The parsed MCPConfig - * @throws Error if the file cannot be loaded, parsed, or is invalid + * @returns The parsed MCPConfig with normalized server types + * @throws Error if the file is missing, cannot be loaded, parsed, or is invalid */ -export function loadMcpServersConfig(configPath: string): MCPConfig { +function loadMcpServersConfig(configPath: string): MCPConfig { try { const resolvedPath = resolve(process.cwd(), configPath); + if (!existsSync(resolvedPath)) { + throw new Error(`Config file not found: ${resolvedPath}`); + } const configContent = readFileSync(resolvedPath, "utf-8"); const config = JSON.parse(configContent) as MCPConfig; @@ -24,7 +113,13 @@ export function loadMcpServersConfig(configPath: string): MCPConfig { throw new Error("Configuration file must contain an mcpServers element"); } - return config; + const normalizedServers: Record = {}; + for (const [name, raw] of Object.entries(config.mcpServers)) { + normalizedServers[name] = normalizeServerType( + raw as unknown as Record & { type?: string }, + ); + } + return { ...config, mcpServers: normalizedServers }; } catch (error) { if (error instanceof Error) { throw new Error(`Error loading configuration: ${error.message}`); @@ -34,116 +129,229 @@ export function loadMcpServersConfig(configPath: string): MCPConfig { } /** - * Converts CLI arguments to MCPServerConfig format. - * Handles all CLI-specific logic including: - * - Detecting if target is a URL or command - * - Validating transport/URL combinations - * - Auto-detecting transport type from URL path - * - Converting CLI's "http" transport to "streamable-http" - * - * @param args - CLI arguments object with target (URL or command), transport, and headers - * @returns MCPServerConfig suitable for creating an InspectorClient - * @throws Error if arguments are invalid (e.g., args with URLs, stdio with URLs, etc.) + * Loads a single server config from an MCP config file by name. + * Delegates to loadMcpServersConfig (file existence and type normalization are done there). */ -export function argsToMcpServerConfig(args: { - target: string[]; - transport?: "sse" | "stdio" | "http"; - headers?: Record; - env?: Record; -}): MCPServerConfig { - if (args.target.length === 0) { +function loadServerFromConfig( + configPath: string, + serverName: string, +): MCPServerConfig { + const config = loadMcpServersConfig(configPath); + if (!config.mcpServers[serverName]) { + const available = Object.keys(config.mcpServers).join(", "); throw new Error( - "Target is required. Specify a URL or a command to execute.", + `Server '${serverName}' not found in config file. Available servers: ${available}`, ); } + return config.mcpServers[serverName]; +} - const [firstTarget, ...targetArgs] = args.target; - - if (!firstTarget) { - throw new Error("Target is required."); - } - - const isUrl = - firstTarget.startsWith("http://") || firstTarget.startsWith("https://"); +/** Build one MCPServerConfig from ad-hoc options (no config file). */ +function buildConfigFromOptions(options: ServerConfigOptions): MCPServerConfig { + const target = options.target ?? []; + const first = target[0]; + const rest = target.slice(1); - // Validation: URLs cannot have additional arguments - if (isUrl && targetArgs.length > 0) { - throw new Error("Arguments cannot be passed to a URL-based MCP server."); - } + const urlFromTarget = + first && (first.startsWith("http://") || first.startsWith("https://")) + ? first + : null; + const url = urlFromTarget ?? options.serverUrl ?? null; - // Validation: Transport/URL combinations - if (args.transport) { - if (!isUrl && args.transport !== "stdio") { - throw new Error("Only stdio transport can be used with local commands."); - } - if (isUrl && args.transport === "stdio") { - throw new Error("stdio transport cannot be used with URLs."); + if (url) { + if (rest.length > 0 && urlFromTarget) { + throw new Error("Arguments cannot be passed to a URL-based MCP server."); } - } - - // Handle URL-based transports (SSE or streamable-http) - if (isUrl) { - const url = new URL(firstTarget); - - // Determine transport type let transportType: "sse" | "streamable-http"; - if (args.transport) { - // Convert CLI's "http" to "streamable-http" - if (args.transport === "http") { - transportType = "streamable-http"; - } else if (args.transport === "sse") { - transportType = "sse"; - } else { - // Should not happen due to validation above, but default to SSE - transportType = "sse"; - } + const t = + options.transport === "http" ? "streamable-http" : options.transport; + if (t === "sse" || t === "streamable-http") { + transportType = t; } else { - // Auto-detect from URL path - if (url.pathname.endsWith("/mcp")) { + const u = new URL(url); + if (u.pathname.endsWith("/mcp")) { transportType = "streamable-http"; - } else if (url.pathname.endsWith("/sse")) { + } else if (u.pathname.endsWith("/sse")) { transportType = "sse"; } else { - // Default to SSE if path doesn't match known patterns - transportType = "sse"; + throw new Error( + `Transport type not specified and could not be determined from URL: ${url}.`, + ); } } - - // Create SSE or streamable-http config if (transportType === "sse") { - const config: SseServerConfig = { - type: "sse", - url: firstTarget, - }; - if (args.headers) { - config.headers = args.headers; - } - return config; - } else { - const config: StreamableHttpServerConfig = { - type: "streamable-http", - url: firstTarget, - }; - if (args.headers) { - config.headers = args.headers; + const config: SseServerConfig = { type: "sse", url }; + if (options.headers && Object.keys(options.headers).length > 0) { + config.headers = options.headers; } return config; } + const config: StreamableHttpServerConfig = { type: "streamable-http", url }; + if (options.headers && Object.keys(options.headers).length > 0) { + config.headers = options.headers; + } + return config; } - // Handle stdio transport (command-based) - const config: StdioServerConfig = { - type: "stdio", - command: firstTarget, - }; - - if (targetArgs.length > 0) { - config.args = targetArgs; + if (target.length === 0 || !first) { + throw new Error( + "Target is required. Specify a URL or a command to execute.", + ); } - if (args.env && Object.keys(args.env).length > 0) { - config.env = args.env; + if (options.transport && options.transport !== "stdio") { + throw new Error("Only stdio transport can be used with local commands."); } + const config: StdioServerConfig = { type: "stdio", command: first }; + if (rest.length > 0) config.args = rest; + if (options.env && Object.keys(options.env).length > 0) + config.env = options.env; + if (options.cwd?.trim()) config.cwd = options.cwd.trim(); + return config; +} + +/** Apply env/cwd overrides to a stdio config; headers to sse/streamable-http. */ +function applyOverrides( + config: MCPServerConfig, + overrides: { + env?: Record; + cwd?: string; + headers?: Record; + }, +): MCPServerConfig { + if (config.type === "stdio") { + const c = { ...config } as StdioServerConfig; + if (overrides.env && Object.keys(overrides.env).length > 0) { + c.env = { ...(c.env ?? {}), ...overrides.env }; + } + if (overrides.cwd) c.cwd = overrides.cwd; + return c; + } + if (config.type === "sse" || config.type === "streamable-http") { + const c = { ...config }; + if (overrides.headers && Object.keys(overrides.headers).length > 0) { + c.headers = { ...(c.headers ?? {}), ...overrides.headers }; + } + return c; + } return config; } + +export type ResolveServerConfigsMode = "single" | "multi"; + +/** + * Resolves server config(s) from options and mode. Used by all runners. + * Single mode: one config (from file + overrides, or from args). + * Multi mode: all servers from file (with optional env/cwd/headers overrides), or one from args; errors if config path + transport/serverUrl/positional. + */ +export function resolveServerConfigs( + options: ServerConfigOptions, + mode: ResolveServerConfigsMode, +): MCPServerConfig[] { + const hasConfigPath = Boolean(options.configPath?.trim()); + const hasAdHoc = + (options.target && options.target.length > 0) || + Boolean(options.transport) || + Boolean(options.serverUrl); + + if (mode === "single") { + if (hasConfigPath && options.serverName) { + const config = loadServerFromConfig( + options.configPath!, + options.serverName, + ); + return [ + applyOverrides(config, { + env: options.env, + cwd: options.cwd, + headers: options.headers, + }), + ]; + } + if (hasConfigPath && !options.serverName) { + const configPath = options.configPath!; + const mcpConfig = loadMcpServersConfig(configPath); + const servers = Object.keys(mcpConfig.mcpServers); + if (servers.length === 0) + throw new Error("No servers found in config file"); + if (servers.length > 1) { + throw new Error( + `Multiple servers found in config file. Please specify one with --server. Available servers: ${servers.join(", ")}`, + ); + } + const serverName = servers[0]; + if (!serverName) throw new Error("No servers found in config file"); + const config = loadServerFromConfig(configPath, serverName); + return [ + applyOverrides(config, { + env: options.env, + cwd: options.cwd, + headers: options.headers, + }), + ]; + } + return [buildConfigFromOptions(options)]; + } + + if (mode === "multi") { + if (hasConfigPath && hasAdHoc) { + throw new Error( + "In multi-server mode with a config file, do not pass --transport, --server-url, or positional command/URL. Use only --config with optional -e, --cwd, --header.", + ); + } + if (hasConfigPath && options.configPath) { + const configPath = options.configPath; + const mcpConfig = loadMcpServersConfig(configPath); + const configs = Object.values(mcpConfig.mcpServers).map((c) => + applyOverrides({ ...c } as MCPServerConfig, { + env: options.env, + cwd: options.cwd, + headers: options.headers, + }), + ); + return configs; + } + return [buildConfigFromOptions(options)]; + } + + return []; +} + +/** + * Returns named server configs from a config file (multi-server). Use when the caller + * needs server names (e.g. TUI). Errors if config path is missing or if ad-hoc options + * (target, transport, serverUrl) are also provided. + */ +export function getNamedServerConfigs( + options: ServerConfigOptions, +): Record { + const hasConfigPath = Boolean(options.configPath?.trim()); + const hasAdHoc = + (options.target && options.target.length > 0) || + Boolean(options.transport) || + Boolean(options.serverUrl); + + if (!hasConfigPath) { + throw new Error("Config path is required for getNamedServerConfigs."); + } + if (hasAdHoc) { + throw new Error( + "With a config file, do not pass --transport, --server-url, or positional command/URL. Use only --config with optional -e, --cwd, --header.", + ); + } + + const mcpConfig = loadMcpServersConfig(options.configPath!); + const result: Record = {}; + for (const [name, config] of Object.entries(mcpConfig.mcpServers)) { + result[name] = applyOverrides( + { ...config }, + { + env: options.env, + cwd: options.cwd, + headers: options.headers, + }, + ); + } + return result; +} diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts index 8e57d0d5e..c9599b73a 100644 --- a/core/mcp/node/index.ts +++ b/core/mcp/node/index.ts @@ -1,2 +1,9 @@ -export { loadMcpServersConfig, argsToMcpServerConfig } from "./config.js"; +export { + parseKeyValuePair, + parseHeaderPair, + resolveServerConfigs, + getNamedServerConfigs, + type ServerConfigOptions, + type ResolveServerConfigsMode, +} from "./config.js"; export { createTransportNode } from "./transport.js"; diff --git a/core/mcp/remote/node/index.ts b/core/mcp/remote/node/index.ts index 0bff99cd4..cdf3e9314 100644 --- a/core/mcp/remote/node/index.ts +++ b/core/mcp/remote/node/index.ts @@ -6,6 +6,7 @@ export { createRemoteApp, type RemoteServerOptions, type CreateRemoteAppResult, + type InitialConfigPayload, } from "./server.js"; // Re-export constants from base remote directory (browser-safe) export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "../constants.js"; diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 1646493f9..386fc8448 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -27,6 +27,19 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; +/** + * Shape of the initial config returned by GET /api/config (defaults for client). + */ +export interface InitialConfigPayload { + defaultCommand?: string; + defaultArgs?: string[]; + defaultTransport?: string; + defaultServerUrl?: string; + defaultHeaders?: Record; + defaultCwd?: string; + defaultEnvironment: Record; +} + export interface RemoteServerOptions { /** Optional auth token. If not provided, uses API_SERVER_ENV_VARS.AUTH_TOKEN env var or generates one. Ignored when dangerouslyOmitAuth is true. */ authToken?: string; @@ -49,6 +62,9 @@ export interface RemoteServerOptions { /** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */ sandboxUrl?: string; + + /** Initial config for GET /api/config. Caller must pass this (e.g. from webServerConfigToInitialPayload(config)). */ + initialConfig: InitialConfigPayload; } export interface CreateRemoteAppResult { @@ -169,89 +185,6 @@ function createAuthMiddleware(authToken: string) { }; } -/** - * Build initial config object from process.env for GET /api/config. - * Same shape as previously injected via __INITIAL_CONFIG__. - */ -function buildInitialConfigFromEnv(): { - defaultCommand?: string; - defaultArgs?: string[]; - defaultTransport?: string; - defaultServerUrl?: string; - defaultHeaders?: Record; - defaultCwd?: string; - defaultEnvironment: Record; -} { - const defaultEnvKeys = - process.platform === "win32" - ? [ - "APPDATA", - "HOMEDRIVE", - "HOMEPATH", - "LOCALAPPDATA", - "PATH", - "PROCESSOR_ARCHITECTURE", - "SYSTEMDRIVE", - "SYSTEMROOT", - "TEMP", - "USERNAME", - "USERPROFILE", - "PROGRAMFILES", - ] - : ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; - - const defaultEnvironment: Record = {}; - for (const key of defaultEnvKeys) { - const value = process.env[key]; - if (value && !value.startsWith("()")) { - defaultEnvironment[key] = value; - } - } - if (process.env.MCP_ENV_VARS) { - try { - Object.assign( - defaultEnvironment, - JSON.parse(process.env.MCP_ENV_VARS) as Record, - ); - } catch { - // Ignore invalid MCP_ENV_VARS - } - } - - return { - ...(process.env.MCP_INITIAL_COMMAND - ? { defaultCommand: process.env.MCP_INITIAL_COMMAND } - : {}), - ...(process.env.MCP_INITIAL_ARGS - ? { defaultArgs: process.env.MCP_INITIAL_ARGS.split(" ") } - : {}), - ...(process.env.MCP_INITIAL_TRANSPORT - ? { defaultTransport: process.env.MCP_INITIAL_TRANSPORT } - : {}), - ...(process.env.MCP_INITIAL_SERVER_URL - ? { defaultServerUrl: process.env.MCP_INITIAL_SERVER_URL } - : {}), - ...(process.env.MCP_INITIAL_HEADERS - ? (() => { - try { - const parsed = JSON.parse( - process.env.MCP_INITIAL_HEADERS, - ) as Record; - return Object.keys(parsed).length > 0 - ? { defaultHeaders: parsed } - : {}; - } catch { - return {}; - } - })() - : {}), - ...(process.env.MCP_INITIAL_CWD - ? { defaultCwd: process.env.MCP_INITIAL_CWD } - : {}), - defaultEnvironment, - }; -} - /** * Simple OAuth client provider that just returns tokens. * Used by remote server to inject Bearer tokens into transport requests. @@ -333,7 +266,7 @@ function forwardLogEvent( } export function createRemoteApp( - options: RemoteServerOptions = {}, + options: RemoteServerOptions, ): CreateRemoteAppResult { const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth; @@ -359,10 +292,9 @@ export function createRemoteApp( } app.get("/api/config", (c) => { - const initialConfig = buildInitialConfigFromEnv(); const payload = options.sandboxUrl - ? { ...initialConfig, sandboxUrl: options.sandboxUrl } - : initialConfig; + ? { ...options.initialConfig, sandboxUrl: options.sandboxUrl } + : options.initialConfig; return c.json(payload); }); diff --git a/docs/inspector-client-sub-managers.md b/docs/inspector-client-sub-managers.md deleted file mode 100644 index 010cbcbd7..000000000 --- a/docs/inspector-client-sub-managers.md +++ /dev/null @@ -1,130 +0,0 @@ -# InspectorClient: Internal Sub-Managers - -This document describes **internal** sub-managers that `InspectorClient` could delegate to in order to reduce the size and complexity of the main class. List state, log state, and requestor task list are handled by **external** state managers (see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)). In scope here: receiver tasks, OAuth, pending samples/elicitation, and optionally roots. - ---- - -## Why Internal Delegation (Sub-Managers) - -### Single entry point and lifecycle - -Consumers expect one client object: one `connect()` / `disconnect()`, one place for environment (transport, fetch, logger, OAuth). Sub-managers are **internal** implementation details. The public API stays on `InspectorClient`; callers do not see or depend on the managers. - -### Shared state and the SDK client - -The MCP SDK `Client` is the single handle to the protocol. OAuth state, receiver task records, and pending samples/elicitation all depend on that connection. Extracting them into **internal** helpers (not separate public classes) keeps one facade that owns the SDK client and delegates to narrow, testable modules. - -### Testability and change isolation - -Managers can be unit-tested in isolation with mocks. Changes to OAuth or receiver-task handling are confined to one module. The main class becomes mostly wiring and delegation. - ---- - -## Sub-Managers - -The following are implemented inside InspectorClient and are candidates for internal extraction. - -### 1. ReceiverTaskManager - -**Responsibility:** Server-initiated tasks: create task record, TTL cleanup, hold payload promise, upsert/cancel, and notify the server of status. - -**State:** `receiverTaskRecords` map, TTL config. - -**Methods:** `createReceiverTask`, `emitReceiverTaskStatus`, `upsertReceiverTask`, `getReceiverTask`, `listReceiverTasks`, `getReceiverTaskPayload`, `cancelReceiverTask` (and helpers like `isTerminalTaskStatus`). - -**Interface:** Constructor takes `{ ttlMs, onEmitStatus: (task) => void, logger? }`. API: `create(opts)`, `get(taskId)`, `list()`, `getPayload(taskId)`, `upsert(task)`, `cancel(taskId)`. - -**Usage:** Created when the client is set up. Message/elicit handlers call the manager to create records; `tasks/get`, `tasks/cancel`, and `tasks/list` handlers delegate to the manager. Manager calls `onEmitStatus` to send status notifications (InspectorClient implements that with the SDK client). - ---- - -### 2. OAuthManager (OAuthFlowManager) - -**Responsibility:** OAuth config and all OAuth flow orchestration (normal and guided), using existing `OAuthStateMachine` and `BaseOAuthClientProvider`. - -**State:** `oauthConfig`, `oauthStateMachine`, `oauthState`. - -**Methods:** `setOAuthConfig`, `authenticate`, `beginGuidedAuth`, `runGuidedAuth`, `setGuidedAuthorizationCode`, `completeOAuthFlow`, `getOAuthTokens`, `clearOAuthTokens`, `isOAuthAuthorized`, `getOAuthState`, `getOAuthStep`, `proceedOAuthStep`, and private helpers (e.g. `createOAuthProvider`, `getServerUrl` or a callback). - -**Interface:** Constructor takes `getServerUrl`, `fetch`, `logger`, `getEventTarget`, and OAuth env (storage, navigation, redirectUrlProvider). Same public method signatures as today. - -**Usage:** InspectorClient holds the manager when `options.oauth` is present. All public OAuth methods delegate to the manager. - ---- - -### 3. PendingSamplesElicitationManager - -**Responsibility:** Hold pending sampling and elicitation requests and notify when they change. - -**State:** `pendingSamples`, `pendingElicitations`. - -**Methods:** `getPendingSamples`, `addPendingSample`, `removePendingSample`, `getPendingElicitations`, `addPendingElicitation`, `removePendingElicitation`. - -**Interface:** Constructor takes a dispatch callback. API: get/add/remove for each list. - -**Usage:** InspectorClient holds the manager and delegates all six methods. CreateMessage/elicit and elicitation-complete handlers call into the manager. - ---- - -## Optional Extraction - -**Roots:** InspectorClient holds `roots` and exposes `getRoots()`, `setRoots()`, and dispatches `rootsChange`. This could stay as-is or be moved into a small internal **RootsManager** if desired; it is not a state manager in the external sense (no separate consumer-facing list). - -**Content cache:** If present, can remain as an internal dependency or small helper. - ---- - -## What Remains in InspectorClient After Sub-Manager Extraction - -After extracting ReceiverTaskManager, OAuthManager, and PendingSamplesElicitationManager (and optionally roots), InspectorClient would retain: - -**Lifecycle and transport** - -- Constructor: validate options, create sub-managers (injecting adapters/callbacks), set config flags. -- `connect()`: create transport (and optionally wrap with MessageTrackingTransport), attach listeners, register protocol request/notification handlers (which call into ReceiverTaskManager, etc.), run initialize, set up list-changed and other notification handlers. -- `disconnect()`: close transport, clear references, reset or clear managers as needed. - -**SDK client and environment** - -- Owning the MCP SDK `Client` and the transport. -- `getRequestOptions()`, `buildEffectiveAuthFetch()`, and other small helpers used by multiple managers or by `connect()`. - -**Thin public API (delegation)** - -- All existing public methods remain on InspectorClient; many become one-liners delegating to the appropriate manager (OAuth, receiver tasks, pending samples/elicitation) or to the SDK client (list RPCs, ping, callTool, callToolStream, readResource, getPrompt, createMessage, elicit, subscribe/unsubscribe, setLoggingLevel, etc.). -- List and log data come from **external** state managers, not from InspectorClient getters. - -**Protocol and app-renderer wiring** - -- Registration of request/notification handlers in `connect()` that coordinate with ReceiverTaskManager (createMessage/elicit with task, tasks/get, tasks/cancel, tasks/list). -- `getAppRendererClient()` (proxy for the Apps tab). -- Dispatching signal events (\*ListChanged, taskStatusChange, requestorTaskUpdated, message, fetchRequest, stderrLog, saveSession, etc.) so external state managers can subscribe. - -**Session id and roots** - -- `getSessionId()`, `setSessionId()`; the decision of when to dispatch `saveSession` (e.g. before OAuth redirect) remains on InspectorClient. Actual persist/restore is in FetchRequestLogState. -- Roots (and optionally a small RootsManager) unless extracted. - ---- - -## Testing Strategy - -**Dedicated test module per sub-manager.** Add a test file for each extracted manager (e.g. `receiverTaskManager.test.ts`, `oauthManager.test.ts`, `pendingSamplesElicitationManager.test.ts`). Test each manager in isolation with **mocked** dependencies (adapters, dispatch, getRequestOptions). No real transport or full InspectorClient. - -**Move tests from InspectorClient into manager tests when they only validate manager behavior.** Examples: receiver task TTL expiry and cleanup, cancel semantics, OAuth state transitions, pending sample add/remove. After extraction, those scenarios live in the manager tests; InspectorClient tests can drop or reduce equivalent coverage. - -**InspectorClient tests focus on wiring and integration.** Verify (1) the client correctly delegates to each manager, and (2) a small set of end-to-end flows per domain (e.g. connect, server sends createMessage with params.task, client creates a receiver task and responds to tasks/get). Manager tests own detailed scenarios; client tests own lifecycle, delegation, and integration. - ---- - -## Rough Code Impact (Remaining Sub-Managers Only) - -- **Current:** InspectorClient is protocol-only for lists and logs; remaining size is connection, OAuth, receiver tasks, pending samples/elicitation, roots, RPC/stream delegation, and handler registration. -- **Moved out (rough, if all three are extracted):** - - ReceiverTaskManager: ~100–120 lines. - - OAuthManager: ~350–400 lines. - - PendingSamplesElicitationManager: ~40–50 lines. -- **Total moved:** on the order of **500–570 lines** into dedicated modules. -- **Remaining:** Wiring, lifecycle, list RPCs (stateless), notification dispatch, request handlers that delegate to ReceiverTaskManager, roots, sessionId/saveSession, and thin public methods that delegate to managers or the SDK client. - -Line counts are approximate; actual impact depends on boundaries and formatting. diff --git a/docs/inspector-client-todo.md b/docs/inspector-client-todo.md index f07fcdd03..89cfe032d 100644 --- a/docs/inspector-client-todo.md +++ b/docs/inspector-client-todo.md @@ -103,7 +103,6 @@ If we are in a container: - No more process spawning (just call the main/run of the desired app) - See: [launcher-config-consolidation-plan.md](launcher-config-consolidation-plan.md) - **InspectorClient sub-managers** (architecture and responsibilities) - - See: [inspector-client-sub-managers.md](inspector-client-sub-managers.md) - Research oauth device flow (esp for CLI/TUI) - Do a spike with alpha v2 TypeScript SDK when published: https://ts.sdk.modelcontextprotocol.io/v2/ - Leverage migration skill: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration-SKILL.md @@ -114,6 +113,10 @@ If we are in a container: ### TUI +Some interaction bugs (esp menu item selection). My guess is this is related to the rollback to React 18 (nothing else should have changed). +@mcp-ui/client has a soon-to-be-deprecated UIResourceRendered that depends on @remote-dom/react, that only supports React 17/18 +See: https://discord.com/channels/1358869848138059966/1471514975171514631 + Close feature gap Follow v2 UX design Implement test strategy (vitest + Playwright?) diff --git a/docs/launcher-config-consolidation-plan.md b/docs/launcher-config-consolidation-plan.md index aad7b146b..61b5d79b4 100644 --- a/docs/launcher-config-consolidation-plan.md +++ b/docs/launcher-config-consolidation-plan.md @@ -1,132 +1,61 @@ -# Launcher and config consolidation plan +# Launcher and config consolidation -## Goal +This document explains the architecture of the `mcp-inspector` application entrypoints and configuration processing. -- One **dedicated launcher** package under `launcher/` (implemented in `main.ts` with Commander, built to `build/main.js`) that only chooses which app to run and forwards argv; no config processing in the launcher. -- One **shared config processor** in **core** that turns MCP server/connection options into a runner config; all apps use it. **All existing config behavior must continue to work:** the processor must honor every supported server-config parameter. -- Each app (web, CLI, TUI) exposes a **runner** that accepts argv, does its own parsing and help, calls the shared processor for server config, and runs. Same behavior when invoked from the launcher or directly. +## How things used to work (and the challenges) ---- +Previously, the project suffered from split config logic and an unnecessary process boundary for the web server: -## Current behavior +1. **Two processes for web and CLI:** The main `mcp-inspector` entrypoint would parse arguments and then `spawn()` a child process for the actual web server (either Vite for dev, or `node dist/server.js` for prod). +2. **Config via environment variables:** To pass config (like server command, transport, auth token, etc.) from the launcher to the child process, the runner serialized everything into an unwieldy list of environment variables (`MCP_INITIAL_*`, `MCP_ENV_VARS`, etc.). +3. **Doesn't scale:** Multi-server or complex config required more environment variables and ad-hoc encoding (e.g. JSON in env). This was fragile and easy to get out of sync. +4. **Config logic split:** Config parsing was duplicated across the web runner, the CLI runner, and the TUI runner. +5. **Direct launch was inconsistent:** Running `npm run dev` or calling workspace binaries directly skipped parts of the launcher logic, leading to inconsistent behavior. -### Entrypoints +## How things work now (Current Design) -| User runs | What executes | Config handling | -| ---------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `mcp-inspector` (or `npm run web`) | `node cli/build/cli.js` (launcher) | Launcher parses argv, does `--config` / `--server` / mcp.json handling | -| `mcp-inspector --web` | Same launcher → **spawns** `node web/bin/start.js` | Launcher resolves config to Args, passes as CLI flags to child | -| `mcp-inspector --cli` | Same launcher → **spawns** `node cli/build/index.js` | Launcher resolves config to command+args+transport+cwd, passes as argv to child | -| `mcp-inspector --tui` | Same launcher → **spawns** `node tui/build/tui.js` | **No** launcher config: raw `process.argv.slice(2)` passed to TUI; TUI parses its own config file path and options | -| `npm run dev` | `node web/bin/start.js --dev` | **No** launcher; web parses argv only (no `--config`/`--server`), so no mcp.json unless you pass equivalent flags manually | -| `mcp-inspector-web` (from web workspace) | `node web/bin/start.js` | Same as above | -| `mcp-inspector-tui` (from TUI workspace) | `node tui/build/tui.js` | TUI parses argv; expects config file path and options | -| `mcp-inspector-cli` (from CLI workspace) | `node cli/build/index.js` | CLI parses argv; expects target (URL or command) + method flags; **no** `--config`/`--server` | +The architecture is now consolidated into a single-process model with a shared configuration processor. -So: +### 1. Dedicated Launcher -- **Config from mcp.json** is only done in the launcher (`cli/src/cli.ts`): `loadConfigFile(configPath, serverName)` returns a single `ServerConfig`; `parseArgs()` then **merges that with all supported CLI options** (e.g. `--cwd`, `-e`, `--header`, args after `--`) and produces `Args` (command, args, transport, serverUrl, cwd, envArgs, etc.). So existing behavior is: file supplies base config; every supported param can override or extend it. -- **Web** never sees `--config` or `--server`; it receives the **resolved** params (e.g. `--transport stdio --cwd /path -- node script.js`). Web's `start.js` parses those flags and env (e.g. `MCP_INITIAL_*`) and passes a config object into the dev or prod client. -- **CLI (inspector-cli)** when spawned gets the same resolved target (command + args) as its positional arguments, plus `--transport`, `--cwd`, etc. It uses its own `parseArgs()` and `argsToMcpServerConfig()` (duplicated logic vs core's `argsToMcpServerConfig` in `core/mcp/node/config.ts`) to build `MCPServerConfig`. -- **TUI** does not use the launcher's config at all when started via `mcp-inspector --tui`; it gets raw argv and requires a config **file path** and supports multiple servers from that file via core's `loadMcpServersConfig()`. +A dedicated package under `launcher/` (`src/index.ts` → `build/index.js`) serves as the global `mcp-inspector` binary. -### Problems with the current design +- **Responsibility:** Its only job is to choose which app to run (`--web`, `--cli`, or `--tui`) and to forward `process.argv`. +- **No spawn:** It dynamically imports the chosen app's runner and calls it **in-process**. -1. **Two processes for web and CLI** - Launcher spawns `node web/bin/start.js` or `node cli/build/index.js`. That means extra process overhead, harder debugging, and serialization of config via argv/env instead of passing a config object. +### 2. Shared Config Processor -2. **Config logic is split and duplicated** - - Launcher: custom `loadConfigFile(configPath, serverName)` and `parseArgs()` that understand `--config`/`--server`, single-server only. - - Core: `loadMcpServersConfig(configPath)` (full file) and `argsToMcpServerConfig(args)` (target + transport → `MCPServerConfig`). - - CLI (index.ts): its own `argsToMcpServerConfig()` (duplicate of core's). - - TUI: uses core's `loadMcpServersConfig`; different UX (config file path + multi-server). - - Web: no config file support; only resolved params via argv/env. +All configuration parsing and merging rules live in **core** (`core/mcp/node/config.ts`). -3. **Direct launch is inconsistent** - - `npm run dev` or `mcp-inspector-web` cannot use `--config mcp.json --server demo`; they'd need to be extended to accept those flags and do the same resolution, or users must use the launcher. - - So "use one entrypoint" vs "run web (or TUI) directly" are at odds: direct run doesn't get launcher's config handling unless we duplicate or share it. +- **Input:** Parsed argument options (file path, server name, env vars, transport, headers) and a mode (`single` or `multi`). +- **Output:** A list of `MCPServerConfig` objects. +- **Benefits:** Web, CLI, and TUI all share the exact same rules for loading config files, applying command-line overrides, and resolving environment variables. -4. **TUI is a special case** - TUI expects a **file path** and multiple servers; launcher is single-server and doesn't pass config file to TUI when using `--tui`. So `mcp-inspector --tui ./mcp.json` would pass `./mcp.json` as a raw arg; TUI would parse it. But launcher's `--config`/`--server` flow (resolve one server from file) is never used for TUI—the comment in code says "we'll integrate config later." +### 3. App Runners ---- +Each app (Web, CLI, TUI) exposes a **runner** function (`runWeb(argv)`, `runCli(argv)`, `runTui(argv)`). -## Design +- The runner uses Commander to parse the arguments. +- It calls the shared core config processor with the relevant server-config subset. +- It receives the config list and starts the application logic. +- Direct launch (e.g., running `mcp-inspector-cli`) just imports the runner and passes `process.argv`. This guarantees identical behavior whether invoked via the launcher or directly. -### 1. Launcher: dedicated package under `launcher/` +### 4. Web Server In-Process (Config as Object) -- A **dedicated launcher package** lives under **`launcher/`** (new workspace, same pattern as `cli/`, `web/`, `tui/`). It is implemented in **`launcher/src/main.ts`** using **Commander** for argument parsing. The package has a build step that compiles to e.g. `launcher/build/main.js`; the root package `bin` for `mcp-inspector` points to that file. -- **Responsibility:** Only to choose which app runs and to forward argv. The launcher: - - Parses argv **only** to detect `--web`, `--cli`, or `--tui` (default when none specified, e.g. `--web`). - - If `-h` or `--help` is present and no mode flag is set, prints launcher help and exits. Launcher help shows **only** the mode options (`--web`, `--cli`, `--tui`) and a note that all other arguments are forwarded to the selected app. - - Does **not** parse, document, or process `--config`, `--server`, or any other server-config or app-specific options. - - Imports the chosen app package and calls its runner with **argv** in-process: `runWeb(process.argv)`, `runCli(process.argv)`, or `runTui(process.argv)`. No subprocess spawn. -- The launcher does **not** call the shared config processor. Config processing is done only inside the runners. +The web app no longer uses `spawn()` or environment variable handoffs. The runner process **is** the web server. -### 2. Core: shared config processor +- **WebServerConfig Object:** The runner builds a typed `WebServerConfig` object (containing port, initial MCP config, auth, etc.) and passes it directly to the server. +- **Vite Dev:** `startViteDevServer(config)` uses Vite's Node API (`createServer` from `vite`) in the same process. It passes the config directly to the Hono Vite plugin via `honoMiddlewarePlugin(config)`. +- **Hono Prod:** `startHonoServer(config)` starts the production server in the same process. +- **Benefits:** + - **Simpler & Scalable:** No env var encoding/decoding. Multi-server config is easily passed as a standard JS object. + - **Easier debugging:** A single Node process means no spawn/kill plumbing and clear shutdown logic. -- The **shared config processor** lives in **core** (e.g. `core/config/` or under `core/mcp/node/`). It is a **library**: it does not parse argv and does not display help. Callers (the runners) parse argv and pass in only the server-config subset. -- **Input:** A structured object of MCP server/connection options (e.g. config path + server name, or command/URL + transport, plus cwd, env, headers, etc.). -- **Output:** A single **`MCPServerConfig`** (the existing type in core that describes how to connect to one MCP server: transport, command/args or serverUrl, cwd, env, headers). -- **Behavior:** When config file + server are provided, load the file, resolve the named server, and merge with any overrides from the options object (CLI overrides win). When no file is provided, build runner config from the options object only (ad-hoc command/URL + transport, etc.). All existing config behavior is preserved. -- **Server-config parameters** (the processor accepts these when provided by the caller; the processor does not read argv itself): `--config` / config path, `--server` / server name; `-e` KEY=VALUE (env), `--transport`, `--server-url`, `--cwd`, `--header`; positional / args after `--` (command and args for stdio). -- **App-specific options** (e.g. `--dev`, `--method`, `--tool-name`) are **not** passed to the processor. Each runner parses argv, extracts the server-config subset, calls the processor, and handles its own options and help. If an app receives a param that does not apply (e.g. `--method` for web or TUI), that app treats it as an error. +### 5. Summary of Execution Flow -### 3. Runners: each app owns parsing, config, and help - -- Each app (web, CLI, TUI) exposes a **runner** function that accepts **argv** (e.g. `runWeb(argv)`, `runCli(argv)`, `runTui(argv)`). The runner is the single code path for that app whether it is invoked from the launcher or from the app's own entrypoint. -- **Each runner:** Parses argv (Commander or equivalent), handles `-h`/`--help` for **that app** (shows that app's full option list including server-config and app-specific options), extracts the server-config subset, calls the **core config processor** with that subset to get `MCPServerConfig`, then runs the app with that config plus any app-specific options (e.g. `--dev`, `--method`). -- Examples: `mcp-inspector --cli -h` → launcher invokes CLI runner with argv; CLI runner sees `-h` and prints CLI help. `mcp-inspector --cli --config mcp.json --server demo --method tools/list` → launcher invokes CLI runner with argv; CLI runner parses everything, calls core processor for server config, runs tools/list. `mcp-inspector-cli --config mcp.json -h` (direct) → app entrypoint calls `runCli(process.argv)`; same behavior. No spawn: when the launcher runs an app, it is the same process. - -### 4. Direct launch - -- Each app continues to have its own entrypoint (e.g. `web/bin/start.js`, `cli/build/index.js`, `tui/build/tui.js`). The entrypoint is a thin wrapper: it calls the app's runner with `process.argv` (e.g. `runWeb(process.argv)`). -- Direct launch and launcher-invoked launch use the **same** runner code; the only difference is whether the process was started by the user running the app binary or by the launcher calling the runner. Behavior is identical. - -### 5. Summary - -| Component | Location | Responsibility | -| -------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Launcher** | **`launcher/`** package (`src/main.ts`, Commander, build → `build/main.js`) | Parse only `--web`/`--cli`/`--tui` and `-h`. Show launcher help (mode options only). Call `runWeb(argv)` / `runCli(argv)` / `runTui(argv)` in-process. No config processing. Root `bin` points to `launcher/build/main.js`. | -| **Config processor** | **Core** (`core/config/` or `core/mcp/node/`) | Library: accept structured server-config options, return `MCPServerConfig`. No argv parsing, no help. Used only by runners. | -| **Runners** | **Web** (`web/`), **CLI** (`cli/`), **TUI** (`tui/`) | Parse argv, show app help, call core config processor with server-config subset, run app. Same behavior when called from launcher or from app entrypoint. | -| **App entrypoints** | Same (e.g. `web/bin/start.js`, `cli/build/index.js`, `tui/build/tui.js`) | Thin wrapper: call runner with `process.argv`. | - -### 6. Phasing - -1. **Core config module** - Add (or extend) a layer in core: e.g. `resolveServerConfig(options)` that accepts a structured object of server-config options (parsed by the caller from argv) and returns a single **`MCPServerConfig`** (the type already used by InspectorClient and transports). Deprecate/remove duplicate `argsToMcpServerConfig` in CLI in favor of this shared processor. - -2. **Web runner** - Refactor so that `runWeb(argv)` is the main API: it parses argv, calls core config processor for the server-config subset, handles `--dev` and `-h`, then runs. Entrypoint `web/bin/start.js` just calls `runWeb(process.argv)`. - -3. **CLI runner** - Refactor so that `runCli(argv)` is the main API: parses argv, calls core config processor for server config, handles `--method`, `-h`, etc., then runs. Entrypoint calls `runCli(process.argv)`. - -4. **TUI runner** - TUI already exports `runTui(args?)`; ensure it accepts argv and does its own parsing and core config processor use where applicable. Entrypoint calls `runTui(process.argv)`. - -5. **Launcher package (`launcher/`)** - Add a new workspace **`launcher/`** with `src/main.ts` using Commander to parse **only** `--web`/`--cli`/`--tui` and `-h`. Show launcher help for `mcp-inspector -h` (only those mode options). Dynamic import of the chosen app and call `runWeb(process.argv)` / `runCli(process.argv)` / `runTui(process.argv)`. No config processing; no spawn. Build to `launcher/build/main.js`; root package `bin` for `mcp-inspector` points to that file. Launcher package has Commander as a dependency. - -6. **Direct launch** - Each app's binary already calls its runner with argv, so direct run (e.g. `mcp-inspector-web`, `mcp-inspector-cli`) behaves identically to launcher-invoked run. - ---- - -## Open questions - -- **TUI multi-server:** TUI currently uses a full config file and multiple servers. Launcher is single-server. Do we want launcher to support "launch TUI with this one server from mcp.json" (same as web/CLI) or always pass through to TUI's own multi-server UX (config file path)? -- **Packaging:** With in-process require, the root package (or launcher package) must depend on web, CLI, and TUI (or have a way to load them). Today's workspace layout already has them; we need to ensure the launcher can import the runners (e.g. from `@modelcontextprotocol/inspector-web`, `@modelcontextprotocol/inspector-cli`, `@modelcontextprotocol/inspector-tui` or relative paths in monorepo). -- **Exit codes and signals:** When the launcher runs web in-process, the web process _is_ the launcher; SIGINT etc. are handled by one process. That can simplify cleanup. We should document expected exit codes for each runner. - ---- - -## References - -- Launcher: `cli/src/cli.ts` (parseArgs, loadConfigFile, runWeb, runCli, runTui). -- Web entry: `web/bin/start.js` (argv parsing, startDevClient / startProdClient). -- CLI entry: `cli/src/index.ts` (parseArgs, argsToMcpServerConfig, callMethod). -- TUI entry: `tui/tui.tsx` (runTui, Commander for config file + options). -- Core config: `core/mcp/node/config.ts` (loadMcpServersConfig, argsToMcpServerConfig). -- Todo: `docs/inspector-client-todo.md` (Misc: "Look at the launcher flow… Single launcher just routes to app…"). +| Component | Responsibility | +| :---------------- | :------------------------------------------------------------------------------------------------------- | +| **Launcher** | Detects `--web`/`--cli`/`--tui` and calls the app runner in-process. | +| **Runner** | Parses argv using Commander and calls the core config processor. | +| **Core Config** | Applies file/cli merging rules, returns `MCPServerConfig` object(s). | +| **App Execution** | Runner uses the config object to directly start Vite API (dev), Hono API (prod), CLI method, or TUI app. | diff --git a/docs/mcp-apps-web-plan.md b/docs/mcp-apps-web-plan.md deleted file mode 100644 index 895601f9e..000000000 --- a/docs/mcp-apps-web-plan.md +++ /dev/null @@ -1,312 +0,0 @@ -# MCP Apps support for Web - implementation plan - -This document outlines a detailed plan to add MCP Apps support to the web app. The plan is informed by **reading PR 1044** (main implementation), PR 1043 (tests), and PR 1075 (tool-result fix), and by the current client and web codebases. - -**Code locations:** On the main/v1 tree the web app lives in `client/` and the proxy/server in `server/`. On this branch the web app lives in `web/`. References to `client/` and `server/` in this document refer to the v1/main tree (e.g. PR 1044); the current app implementation is in `web/`. - -**As-built (Phase 1 completed):** Phase 1 is implemented. Differences from the original plan are noted in **Section 9 (As-built notes)**. Manual verification: all **19 MCP app servers** in `configs/mcpapps.json` have been verified to work in the web app. - -## Phases - -- **Phase 1 (initial):** Get to a working version quickly so we can verify MCP apps functionality. Use a **fixed sandbox port (6277)** and pass a **client proxy** from `InspectorClient.getAppRendererClient()` to mcp-ui (see **AppRendererClient** below). Accept the known limitation: when an app is open, mcp-ui's `setNotificationHandler` overwrites InspectorClient's, so Tools/Resources/Prompts list updates may stop until the app is closed—until Phase 2 multiplexing is implemented. No dynamic port, no sandbox API. -- **Phase 2 (follow-on):** Production-ready plumbing. **Dynamic port and on-demand sandbox server** (ephemeral server, bind to port 0 or on-demand start; expose sandbox URL via API). **Notification multiplexing:** use the existing **AppRendererClient** proxy’s `setNotificationHandler` interception to route app handlers to InspectorClient’s multiplexer (`addAppNotificationHandler`), so both InspectorClient and the app receive notifications. Phase 2 removes the single-handler limitation and avoids fixed-port conflicts. - -## 1. Context and references - -### 1.1 Current state - -- **Client package (reference; we are not modifying it):** Has full MCP Apps support: - - **AppsTab** - lists tools with `_meta.ui.resourceUri`, app selection, input form, open/close/maximize. - - **AppRenderer** - embeds the app UI via `@mcp-ui/client`'s `McpUiAppRenderer` with sandbox, tool input, and host callbacks. - - Sandbox URL comes from the MCP proxy: `${getMCPProxyAddress(config)}/sandbox`. - - Tab is always available; `listTools` is triggered when the Apps tab is active and server supports tools. -- **Web:** Uses InspectorClient + useInspectorClient; has Tools, Resources, Prompts, etc., but **no Apps tab** and no MCP Apps dependencies. - -### 1.2 Reference PRs (read and use to inform this plan) - -- **PR 1044** - **Main implementation PR.** "Add MCP Apps support to Inspector." Implements detection, listing, and interactive rendering of MCP apps with full bidirectional communication. Details from the PR: - - **New components:** AppsTab (detects/displays tools with `_meta.ui.resourceUri`), AppRenderer (full MCP App lifecycle, AppBridge integration). - - **Files added:** `client/src/components/AppsTab.tsx`, `AppRenderer.tsx`, their tests; **server/static/sandbox_proxy.html** - sandbox proxy refactored to match [basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host): nested iframe for security, `sandbox.ts` logic inlined in the HTML. Served on a **different port** via the proxy (client uses `getMCPProxyAddress(config)` + `/sandbox`). The **server** (Express) already has the `/sandbox` endpoint (rate-limited, no-cache) that serves this file; web only needs to point at the correct sandbox URL (same origin when web is served with that server, or configured base). - - **Files modified in 1044:** `client/App.tsx` (Apps tab integration, auto-fetch when tab active, resource wiring); **ListPane** - only show Clear button if `clearItems` is passed (AppsTab does not pass it); ListPane.test, AuthDebugger.test (relative imports); client package.json (ext-apps, mcp-ui/client); client jest.config.cjs (ES modules from ext-apps); **server/src/index.ts** (add `/sandbox` endpoint, serve sandbox_proxy.html, rate limiting); server package.json (shx, express-rate-limit, copy static to build). - - **Features (from PR):** App detection; auto-population when Apps tab becomes active; resource fetching for `ui://` via MCP; MCP-UI AppRenderer integration (client, tool name, resource content, host context); PostMessage transport (JSON-RPC iframe ↔ host); sandboxed rendering with configurable permissions; theme (light/dark); error handling. - - **Architecture (from PR):** Host: AppsTab → AppRenderer → MCP Client; AppBridge ↔ MCP Server; PostMessage transport; sandboxed iframe (MCP App UI). Use this when porting so web matches the same flow. - - **Tests in 1044:** AppsTab 18 tests (detection/filtering, grid, refresh, error, selection/deselection, dynamic tool list, resource content). AppRenderer 8 tests (loading, resource fetching, error states, iframe attributes, AppBridge init/connection, JSON/HTML parsing, permissions, lifecycle). PR 1043 added more; mirror coverage in web with Vitest. - - **PR discussion notes:** Console.logs were left in for debugging; remove or gate when porting. Nested iframe in sandbox addressed CodeQL XSS concerns. Some example servers (budget-allocator, pdf-server, etc.) didn't work initially; budget-allocator is fixed by PR 1075 (tool result). Advanced sandbox permissions may be a follow-up. -- **PR 1043** - Adds comprehensive tests for MCP Apps (AppsTab + AppRenderer). Use for test patterns and Jest config (transform for `@modelcontextprotocol/ext-apps` ESM); adapt to Vitest for web. -- **PR 1075** - Bug fix: Apps tab was missing **tool result** data. Apps received `ui/notifications/tool-input` but not `ui/notifications/tool-result`, breaking apps that depend on initial tool result (e.g. budget allocator). Fix: run tools/call when app/tool input changes, pass result into the iframe via `toolResult` (emits `ui/notifications/tool-result`), with AbortController + run-id for stale-result safety and error fallback (failed tools/call → error result with `isError: true`). **When porting AppRenderer to web, include this tool-result behavior.** - -### 1.3 Client dependencies (MCP Apps) - -- `@mcp-ui/client` - `AppRenderer` (McpUiAppRenderer), `McpUiHostContext`, `RequestHandlerExtra`. -- `@modelcontextprotocol/ext-apps` - `getToolUiResourceUri` from `app-bridge`; types `McpUiMessageRequest`, `McpUiMessageResult` (used by AppRenderer). - -### 1.4 MCP primitives, bridge, and InspectorClient - -**What MCP Apps use (ext-apps spec + @mcp-ui/client):** - -- **Primitives:** Standard MCP only. The View (iframe) asks the Host to run **resources/read** (including `ui://` URIs for the app HTML) and **tools/call**. The Host bridges by passing the **raw MCP Client** to McpUiAppRenderer; the library calls `client.request({ method: "resources/read", params: { uri } })` and `client.request({ method: "tools/call", params })` on that client. So the transport and primitives are the same connection InspectorClient already uses. -- **Events server → host:** There are no app-specific server notifications. The Host gets tool results from **responses** to tools/call and then pushes `ui/notifications/tool-input` / `ui/notifications/tool-result` to the View over postMessage. So "events" to the app are synthesized by the Host from data it already has. -- **Bidirectional bridge:** Yes. The client (host) must bridge comms back to the server: when the app calls a tool or reads a resource, the Host uses the same MCP connection to perform the request. Web passes **AppRendererClient** from `getAppRendererClient()` to mcp-ui (see 1.5). - -**Why we use AppRendererClient (and not the raw Client):** - -- The **SDK Client** allows only **one** `setNotificationHandler` per notification method. **InspectorClient** registers handlers in `connect()` for `notifications/tools/list_changed`, etc. **@mcp-ui/client** also registers for those same methods when McpUiAppRenderer mounts. If we passed the raw SDK Client into AppRenderer, mcp-ui's `setNotificationHandler` would overwrite InspectorClient's and list updates would break while an app is open. -- **Solution (implemented):** InspectorClient no longer exposes the raw client. It exposes **`getAppRendererClient()`**, which returns an **AppRendererClient**—a **proxy** that delegates to the internal MCP Client. The proxy **intercepts only `setNotificationHandler`**. In Phase 1 the interception is a pass-through (we can add behavior later). In **Phase 2** we will route intercepted `setNotificationHandler` calls to InspectorClient's multiplexer (`addAppNotificationHandler`) so both InspectorClient and the app receive notifications. Web receives `appRendererClient` from the hook and passes it to AppsTab/AppRenderer. - -**Phase 1 vs Phase 2** - -- **Phase 1 (as-built):** Web passes **`appRendererClient`** from `useInspectorClient` (which calls `inspectorClient.getAppRendererClient()`) to AppRenderer. The proxy is **cached** in InspectorClient so the same reference is returned for the lifetime of the connection—avoiding React effect loops. Known limitation: when an app is open, mcp-ui's `setNotificationHandler` still overwrites InspectorClient's (interception is pass-through until Phase 2), so Tools/Resources/Prompts lists may not update until the app is closed. -- **Phase 2:** Use the existing **AppRendererClient** proxy’s `setNotificationHandler` hook: instead of forwarding to the internal client, call **`inspectorClient.addAppNotificationHandler(schema, handler)`**. InspectorClient’s single SDK registration (in `connect()`) will dispatch to its own logic and to all handlers registered via `addAppNotificationHandler`. Web already passes the proxy; no change needed. Optional later: shared "is app tool" helper (e.g. wrap `getToolUiResourceUri`). - -### 1.5 AppRendererClient proxy and notification multiplexing - -**What we implemented:** InspectorClient exposes **`getAppRendererClient(): AppRendererClient | null`**. The return type **AppRendererClient** is a type alias for the MCP SDK `Client`; it denotes the app-renderer–scoped proxy, not the raw client. The hook exposes **`appRendererClient`** (not `client`); web passes **`appRendererClient`** to AppsTab and AppRenderer so it’s clear this is the proxy for the Apps tab only. - -**How the proxy works:** - -- The proxy is a **JavaScript `Proxy`** around InspectorClient’s internal MCP Client. It **forwards all property/method access** to the internal client (so `callTool`, `listTools`, `readResource`, `request`, etc. behave identically). -- **Only `setNotificationHandler` is intercepted:** the proxy’s `get` handler returns a wrapper that can add behavior before delegating. Currently the wrapper just forwards to the internal client (pass-through). **Phase 2:** change the wrapper to call **`inspectorClient.addAppNotificationHandler(schema, handler)`** instead of forwarding, so app handlers are registered in a list; InspectorClient’s existing SDK registration in `connect()` will be extended to also invoke every handler in that list. Result: both InspectorClient and the app receive list_changed (and any other notifications). -- The proxy is **cached** in InspectorClient (`appRendererClientProxy`). We create it once when first needed (when connected) and return the same instance until disconnect or reconnect. That keeps the reference stable across React renders and prevents effect loops in AppRenderer. - -**Phase 2 implementation (remaining):** (1) Expose **`addAppNotificationHandler(notificationSchema, handler)`** (and optionally **`removeAppNotificationHandler`**) on InspectorClient. (2) In the AppRendererClient proxy’s `setNotificationHandler` wrapper, call **`addAppNotificationHandler`** instead of forwarding to the internal client. (3) In `connect()`, when registering the single SDK handler per notification method, have that handler run InspectorClient’s existing logic and then call every handler registered via `addAppNotificationHandler` for that method. - -**Result:** Web already calls `inspectorClient.getAppRendererClient()` (via the hook) and passes `appRendererClient` to AppRenderer. Once Phase 2 multiplexing is wired, only the proxy’s `setNotificationHandler` implementation and InspectorClient’s dispatch logic need to change; no web or prop renames required. - ---- - -## 2. High-level approach - -- **Reuse behavior and structure** from the client's AppsTab and AppRenderer. -- **Add** the same npm deps to web (`@mcp-ui/client`, `@modelcontextprotocol/ext-apps`). -- **Implement** in web: AppsTab and AppRenderer (copy/adapt from client, including PR 1075 tool-result handling). -- **Phase 1:** Fixed sandbox port (6277); pass **AppRendererClient** from `getAppRendererClient()` (via hook as `appRendererClient`) to mcp-ui; wire Apps tab with fixed sandbox URL. -- **Phase 2:** Dynamic/on-demand sandbox server and sandbox URL API; notification multiplexing via the existing AppRendererClient’s `setNotificationHandler` interception; web already uses the proxy. -- **Tests:** Add web tests for AppsTab and AppRenderer (Vitest), mirroring client coverage where useful; optionally port or adapt client tests. - ---- - -## 3. Prerequisites and dependencies - -### 3.1 Web package.json - -- Add: - - `@mcp-ui/client` (match client version, e.g. `^6.0.0`). - - `@modelcontextprotocol/ext-apps` (e.g. `^1.0.0`). -- Ensure Vitest (and any bundler config) can load these if they ship ESM (see client's Jest transform for `@modelcontextprotocol` in PR 1043). - -### 3.2 Sandbox URL for web - -- **Spec requirement:** Host and Sandbox must be different origins. The sandbox cannot be same-origin with the web app. -- **Phase 1:** Use a **fixed port (6277)**. Server serves `server/static/sandbox_proxy.html` on port 6277 (e.g. same server as Inspector or a dedicated listener on 6277). Web constructs sandbox URL as `http://:6277` (or from config/base URL) and passes it to AppsTab. No API; no dynamic port. -- **Phase 2:** **Ephemeral sandbox server** - bind to port `0` (OS-assigned) or on-demand start. Expose sandbox base URL via API (e.g. `GET /api/sandbox-url`). Web obtains URL from API and passes to AppsTab. Enables multiple instances and avoids port conflicts. - -### 3.3 UI and utils already in web - -- Web already has: **ListPane**, **IconDisplay**, **DynamicJsonForm**, **Label**, **Checkbox**, **Select**, **Textarea**, **Input**, **Button**, **Alert**, **Tabs**. No need to copy these from client; use web's existing components and paths (e.g. `@/components/ui/...`, `@/utils/schemaUtils`, `@/utils/jsonUtils`). -- **ListPane (PR 1044):** In the client, ListPane was changed so the Clear button is only shown when a `clearItems` prop is passed. AppsTab does not pass `clearItems`, so the Apps list shows no Clear button. When porting, if web's ListPane currently always shows Clear, update it to match: only show Clear when `clearItems` is provided. - ---- - -## 4. Implementation tasks - -### 4.1 Add dependencies (web) - -- In `web/package.json`, add: - - `@mcp-ui/client` - - `@modelcontextprotocol/ext-apps` -- Run install; fix any type or build issues (e.g. Vite/Vitest handling of these packages). - -### 4.2 Sandbox URL (server + web) - -- **Phase 1:** **Server:** Ensure `server/static/sandbox_proxy.html` is served on **fixed port 6277** (e.g. add a dedicated HTTP server/listener on 6277 when the Inspector server starts, or serve sandbox on 6277 from the same process). Apply same security as current `/sandbox` (rate limiting, no-cache, referrer validation in HTML). **Web:** Construct sandbox URL as `http://:6277` using the web app's host (or a configured base). Pass it to AppsTab as `sandboxPath`. No API call. -- **Phase 2:** **Server:** Start an ephemeral sandbox server (bind to port `0`) or on-demand; expose URL via `GET /api/sandbox-url`. **Web:** Fetch sandbox URL from API after connect; pass to AppsTab. If API unavailable, show message or disable app iframe. -- **As-built:** The **web app is self-contained**; the legacy server package is not used. Sandbox is served by the web app itself: (1) **Prod:** `web/src/server.ts` (TypeScript) runs both the Hono app on 6274 and a second `createServer` listener on 6277 that serves GET `/sandbox` from `web/static/sandbox_proxy.html`. Same process, same lifecycle. (2) **Dev:** `web/vite.config.ts` plugin starts the sandbox HTTP server on 6277 in the same Node process as Vite. Sandbox HTML is a copy in `web/static/sandbox_proxy.html` (referrer validation in HTML unchanged). No rate limiting added in as-built. - -### 4.3 Port AppsTab to web - -- **Source:** `client/src/components/AppsTab.tsx`. -- **Target:** `web/src/components/AppsTab.tsx`. -- **Changes:** - - Replace client-specific imports with web paths: - - `@/components/ui/tabs`, `@/components/ui/button`, `@/components/ui/alert`, etc. (already in web). - - `@/utils/jsonUtils`, `@/utils/schemaUtils` (and any schema/param helpers like `generateDefaultValue`, `isPropertyRequired`, `normalizeUnionType`, `resolveRef`) - use web's equivalents; copy from client only if a helper is missing in web. - - Keep the same props interface in spirit: `sandboxPath`, `tools`, `listTools`, `error`, `appRendererClient`, `onNotification`. Types: `Tool[]`, `AppRendererClient | null` (from `getAppRendererClient()` via the hook), `ServerNotification`. - - Keep app detection: `getToolUiResourceUri` from `@modelcontextprotocol/ext-apps/app-bridge`; filter tools with `hasUIMetadata`. - - Keep layout and behavior: ListPane for app list, form for selected app input, AppRenderer when "Open App" is used, maximize/minimize, back to input. - - Remove or replace any client-only references (e.g. `getMCPProxyAddress`); use the new sandbox helper instead. -- **Console logging:** Client has `console.log("[AppsTab] Filtered app tools", ...)`. Prefer removing or gating behind dev/debug so production web stays quiet. - -### 4.4 Port AppRenderer to web (including PR 1075 behavior) - -- **Source:** `client/src/components/AppRenderer.tsx` **plus** the behavior from **PR 1075** (tool result forwarding). -- **Target:** `web/src/components/AppRenderer.tsx`. -- **Changes:** - - Imports: Use web paths for UI (`@/components/ui/alert`, `@/lib/hooks/useToast`), and keep `@mcp-ui/client` and `@modelcontextprotocol/ext-apps` (for types if needed). - - Props: Same as client: `sandboxPath`, `tool`, `appRendererClient` (type `AppRendererClient | null` from `getAppRendererClient()` via the hook), `toolInput`, `onNotification`. **Add** support for **tool result** (PR 1075): either a `toolResult` prop or an internal call to `callTool` and pass result into `McpUiAppRenderer` so the iframe receives `ui/notifications/tool-result`. Implement: - - When tool/toolInput (or initial mount) is ready, call MCP `tools/call` with the selected tool and current arguments (if the app expects initial result). - - Pass the result into the renderer as `toolResult` so the iframe gets `ui/notifications/tool-result`. - - Use AbortController + run-id (or similar) so that when the user switches app or restarts, stale results are ignored. - - On tools/call failure, send an error-shaped result (e.g. `isError: true`) to the app so the UI doesn't hang. - - Host context: Use `document.documentElement.classList.contains("dark")` for theme like client. - - Callbacks: `onOpenLink`, `onMessage` (toast), `onLoggingMessage` (forward to `onNotification`), `onError` (set local error state). - - If the client's AppRenderer was updated in a branch for PR 1075 and this repo's client doesn't have it yet, implement the tool-result logic from the PR description when porting. - -### 4.5 Wire Apps tab in web App.tsx - -- **Tab list:** Add an "Apps" tab (e.g. icon `AppWindow` from lucide-react) with `value="apps"`, placed similarly to client (e.g. after Tools). -- **validTabs:** In every place where `validTabs` is derived (e.g. hash sync and "originating tab" after sampling/requests), add `"apps"` so that: - - Navigating to `#apps` is valid when connected. - - When a request completes and restores the originating tab, `"apps"` can be restored. -- **listTools when Apps tab is active:** Add an effect similar to client: when connected and `activeTab === "apps"` and `serverCapabilities?.tools`, call `listTools()`. (As-built: we use `connectionStatus === "connected"` for the connection check.) This keeps the tools list (and thus app tools) up to date when the user opens the Apps tab. -- **Render AppsTab:** Inside the same Tabs content area as Resources/Prompts/Tools, add: - - `` containing ``. -- **AppsTab props:** - - **Phase 1 (as-built):** `sandboxPath` = fixed URL (e.g. `http://${window.location.hostname}:6277/sandbox`). `appRendererClient={appRendererClient}` where `appRendererClient` is from useInspectorClient (i.e. `getAppRendererClient()`). Accept that Tools/Resources/Prompts list updates may stop while an app is open until Phase 2 multiplexing. - - **Phase 2:** `sandboxPath={sandboxUrl}` from API (e.g. GET /api/sandbox-url). Same `appRendererClient` from hook; proxy’s `setNotificationHandler` interception will route to multiplexer so both InspectorClient and app receive notifications. - - Both phases: `tools={inspectorTools}`, `listTools={() => { clearError("tools"); listTools(); }}`, `error={errors.tools}`, `onNotification={(notification) => setNotifications(prev => [...prev, notification])}`. Reuse the same notifications state used elsewhere. - -### 4.6 Multiplexed notification handling (Phase 2 only) - -- **Design (see 1.5):** The **AppRendererClient** proxy is already implemented in InspectorClient: **`getAppRendererClient()`** returns a cached Proxy that forwards to the internal client and **intercepts `setNotificationHandler`**. Web already passes `appRendererClient` from the hook to AppRenderer. Phase 2 only needs to wire the interception to a multiplexer. -- **Tasks:** - 1. **InspectorClient:** Expose **`addAppNotificationHandler(notificationSchema, handler)`** and optionally **`removeAppNotificationHandler`**. In `connect()`, ensure the single SDK notification registration dispatches to InspectorClient's existing logic and then to every handler registered via `addAppNotificationHandler` for that method. - 2. **AppRendererClient proxy:** In the proxy’s `setNotificationHandler` wrapper (in `getAppRendererClient()`), call **`this.addAppNotificationHandler(schema, handler)`** instead of forwarding to the internal client. App handlers are then in the multiplexer list; InspectorClient’s SDK registration will invoke them. - 3. **Web:** No change; already passes `appRendererClient` from the hook. -- **Scope:** core (InspectorClient multiplexer API and proxy wrapper behavior). -- **Order:** Phase 2; after Phase 1 is working and we want to fix the list-update limitation and add dynamic sandbox. - -### 4.7 Optional: Shared helper for "app tool" detection - -- If we want a single place for "does this tool have app UI?", we could add in shared (e.g. `shared/mcp/appsUtils.ts` or similar) a function that re-exports or wraps `getToolUiResourceUri` (or implements the same check). Then client and web could both import from shared. Defer unless we want to reduce direct dependency on `@modelcontextprotocol/ext-apps` from both apps. - ---- - -## 5. Testing - -### 5.1 Unit tests in web (Vitest) - -- **AppsTab:** Add `web/src/components/__tests__/AppsTab.test.tsx`. Cover: - - No apps available message and `_meta.ui.resourceUri` hint. - - Filtering to only tools with UI metadata. - - Grid/list display, selection, open/close, back to input. - - Refresh (listTools). - - Error display. - - Mock AppRenderer and, if needed, `getToolUiResourceUri` (or use real ext-apps). -- **AppRenderer:** Add `web/src/components/__tests__/AppRenderer.test.tsx`. Cover: - - Waiting state when `appRendererClient` is null. - - Renders McpUiAppRenderer when client is ready; passes toolName, sandbox, hostContext, toolInput (and toolResult if added). - - onMessage → toast. - - Optional: mock tools/call and assert toolResult is passed through (for PR 1075 behavior). -- Use Vitest's equivalent of Jest's module mock for `@mcp-ui/client` and optionally `@modelcontextprotocol/ext-apps` so tests don't load real iframe/sandbox code. Align with how client's AppRenderer.test and AppsTab.test mock these (see client's `__tests__`). - -### 5.2 Integration / manual - -- After wiring: connect web to a server that exposes tools with `_meta.ui.resourceUri` (e.g. an MCP server that serves app UIs). Open Apps tab, select an app, open it, and confirm the iframe loads and receives tool-input and tool-result (e.g. budget allocator example from ext-apps). - -### 5.3 MCP servers for manual testing (ext-apps examples) - -The PRs reference example servers from the [ext-apps](https://github.com/modelcontextprotocol/ext-apps) repo for manual testing. Below is a compiled list with MCP server configs (stdio). Source: ext-apps README "Running the Examples" / "With MCP Clients". Inspector uses the same `mcpServers` structure (type `stdio`, `command`, `args`). - -**Priority from PRs:** (1) **budget-allocator** – validates tool-result (PR 1075); use as the primary manual test. (2) **pdf**, **transcript**, **video-resource** – mentioned as possibly needing advanced sandbox permissions or follow-ups; test after budget-allocator. - -**Recommended first test (budget-allocator):** - -```json -"budget-allocator": { - "type": "stdio", - "command": "npx", - "args": ["-y", "--silent", "--registry=https://registry.npmjs.org/", "@modelcontextprotocol/server-budget-allocator", "--stdio"] -} -``` - -**Other ext-apps example servers (same stdio pattern; replace package name in args):** - -| Server key | NPM package | -| --------------------- | -------------------------------------------------- | -| budget-allocator | @modelcontextprotocol/server-budget-allocator | -| pdf | @modelcontextprotocol/server-pdf | -| transcript | @modelcontextprotocol/server-transcript | -| video-resource | @modelcontextprotocol/server-video-resource | -| map | @modelcontextprotocol/server-map | -| threejs | @modelcontextprotocol/server-threejs | -| shadertoy | @modelcontextprotocol/server-shadertoy | -| sheet-music | @modelcontextprotocol/server-sheet-music | -| wiki-explorer | @modelcontextprotocol/server-wiki-explorer | -| cohort-heatmap | @modelcontextprotocol/server-cohort-heatmap | -| customer-segmentation | @modelcontextprotocol/server-customer-segmentation | -| scenario-modeler | @modelcontextprotocol/server-scenario-modeler | -| system-monitor | @modelcontextprotocol/server-system-monitor | -| basic-react | @modelcontextprotocol/server-basic-react | -| basic-vanillajs | @modelcontextprotocol/server-basic-vanillajs | - -For each: `"type": "stdio", "command": "npx", "args": ["-y", "--silent", "--registry=https://registry.npmjs.org/", "", "--stdio"]`. - -**How to run:** Add one or more entries to your MCP server config (Inspector config file or UI), then connect and open the Apps tab. To run from a local ext-apps clone, see the "Local Development" section in the [ext-apps README](https://github.com/modelcontextprotocol/ext-apps) (build and run from `examples/budget-allocator-server`, etc.). - -**As-built:** Inspector config files for manual testing live in **`configs/`**. **`configs/mcpapps.json`** contains 19 MCP app server entries. **Manual verification:** All 19 servers in `configs/mcpapps.json` have been verified to work in the web app (Apps tab, open app, load and interact). - ---- - -## 6. Order of work (suggested) - -**Phase 1 (working MCP apps sooner)** - -1. Add deps (4.1). -2. Sandbox on fixed port 6277 (4.2 Phase 1): server serves sandbox_proxy.html on 6277; web uses `http://:6277` as sandboxPath. -3. Port AppRenderer (4.4), including tool-result behavior from PR 1075, and add tests (5.1). -4. Port AppsTab (4.3) and add tests (5.1). -5. Wire Apps tab in App (4.5 Phase 1): add tab, validTabs, listTools effect; pass fixed sandbox URL and `appRendererClient` from useInspectorClient (getAppRendererClient()) to AppsTab. -6. Manual check with an MCP server that has app tools (5.2). Verify apps load and work; accept that list updates may stall while an app is open. - -**Phase 2 (release plumbing)** - -7. Ephemeral/dynamic sandbox server and sandbox URL API (4.2 Phase 2); web fetches sandbox URL from API. -8. Multiplexed notification handling (4.6): implement addAppNotificationHandler and wire the AppRendererClient proxy’s setNotificationHandler to it; web already passes appRendererClient. -9. Optional: shared app-tool helper (4.7) and cleanup (e.g. remove debug logs). - ---- - -## 7. Risks and mitigations - -- **Phase 1 - Fixed port 6277:** If port 6277 is already in use, sandbox will fail to start. Document the requirement; Phase 2 (dynamic port) avoids this. -- **Sandbox origin/CSP:** The sandbox runs on a different origin (fixed 6277 in Phase 1; random port in Phase 2). Ensure the sandbox HTML's referrer allowlist (e.g. in `sandbox_proxy.html`) includes the web app's origin when deployed; document if the allowlist must be configured per environment. -- **PR 1075 not in tree:** When porting AppRenderer to web, if the source we port from doesn't yet have the tool-result fix, implement it when porting AppRenderer so web doesn't ship without it (budget-allocator and similar apps depend on it). -- **ESM in tests:** If `@modelcontextprotocol/ext-apps` or `@mcp-ui/client` are ESM-only, configure Vitest (or Vite) to transform them like the client's Jest config (PR 1043) so tests run. -- **Known example gaps (from PR 1044):** Some ext-apps example servers did not work in the first cut (e.g. budget-allocator fixed by 1075; pdf-server, transcript-server, video-resource-server may need advanced sandbox permissions or other follow-ups). Plan for the same "first cut" scope; document known limitations if needed. - ---- - -## 8. Summary checklist - -**Phase 1** (all complete) - -- [x] Add `@mcp-ui/client` and `@modelcontextprotocol/ext-apps` to web (4.1). -- [x] Serve sandbox on fixed port 6277 (4.2 Phase 1); web uses fixed sandbox URL. As-built: web app serves its own sandbox on 6277 in the same process (see Section 9). -- [x] Port AppRenderer to web with tool-result support from PR 1075 (4.4). -- [x] Port AppsTab to web (4.3); remove or gate console.log (per PR 1044 discussion). -- [x] ListPane: only show Clear when `clearItems` passed (optional prop); AppsTab does not pass it (3.3 / PR 1044). -- [x] Add "Apps" tab and validTabs entries in web App; pass appRendererClient (from getAppRendererClient() via hook) and fixed sandbox URL (4.5 Phase 1). -- [x] Effect: listTools when activeTab === "apps" and server has tools (4.5). -- [x] Add Vitest tests for AppsTab and AppRenderer (5.1); parity with client test count (AppsTab 20 tests, AppRenderer 5 tests). -- [x] Manual test with app-capable servers (5.2): all 19 servers in `configs/mcpapps.json` verified in web app. - -**Phase 2** - -- [ ] Ephemeral/dynamic sandbox server and sandbox URL API (4.2 Phase 2); web consumes API. -- [ ] Notification multiplexer (4.6): addAppNotificationHandler + wire AppRendererClient’s setNotificationHandler to it; web already passes appRendererClient. -- [ ] Optional: shared app-tool helper (4.7) and cleanup. - ---- - -## 9. As-built notes (Phase 1) - -Summary of how Phase 1 was actually implemented where it differed from the plan. - -- **Sandbox (4.2):** The web app is self-contained. The legacy server package does not run. Sandbox is served by the web app on fixed port 6277 in the **same process**: (1) **Production:** `web/src/server.ts` (TypeScript) compiles to `dist/server.js`. It starts the Hono app on 6274 and a second `http.createServer` on 6277 that serves GET `/sandbox` and GET `/sandbox/` with `web/static/sandbox_proxy.html` (no-cache headers; referrer validation is in the HTML). (2) **Development:** The Vite plugin in `web/vite.config.ts` starts the same sandbox HTTP server on 6277 when the dev server runs. Sandbox HTML was copied into `web/static/sandbox_proxy.html`. Rate limiting was not added in the as-built implementation. -- **Web server in TypeScript:** The app server lives in `web/src/server.ts` (TypeScript), built with `tsc -p tsconfig.server.json` and emitted as `dist/server.js`. `web/bin/server.js` was removed. `bin/start.js` (the only remaining JS in bin) spawns `dist/server.js` for prod. -- **Sandbox URL in app:** `sandboxPath` is `http://${window.location.hostname}:6277/sandbox` (Phase 1 fixed URL). -- **ListPane:** `clearItems` is optional; the Clear button is only rendered when `clearItems` is provided. AppsTab does not pass `clearItems`. -- **AppRenderer tool-result (PR 1075):** On mount/update, when `appRendererClient`, `tool`, and `toolInput` are set, we call `appRendererClient.callTool({ name, arguments })` and pass the result to `McpUiAppRenderer` as `toolResult`. A run-id ref is used to ignore stale results; on failure we pass an error-shaped result so the app UI does not hang. -- **AppRendererClient and handler interception:** InspectorClient no longer exposes the raw MCP client. It exposes **`getAppRendererClient(): AppRendererClient | null`**. The hook returns **`appRendererClient`** (so naming is consistent in web). The AppRendererClient is a **cached** JavaScript Proxy around the internal client: same instance for the lifetime of the connection (cleared on disconnect and when creating a new client), so React dependency arrays stay stable and the Apps tab does not loop. The proxy forwards all methods; **only `setNotificationHandler` is intercepted**. The interceptor currently passes through to the internal client. Phase 2 will change it to call **`addAppNotificationHandler`** so both InspectorClient and the app receive notifications and list updates continue while an app is open. -- **Configs:** `configs/` holds Inspector config files for manual testing. `configs/mcpapps.json` has 19 MCP app server entries, `configs/mcp.json` has other sample servers. Root `mcp.json` is gitignored via `/mcp.json` (root only); configs in `configs/` are committed. -- **Manual verification:** All 19 MCP app servers in `configs/mcpapps.json` have been manually verified to work in the web app (connect, open Apps tab, select app, open and interact). diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md new file mode 100644 index 000000000..75bc78509 --- /dev/null +++ b/docs/mcp-server-configuration.md @@ -0,0 +1,136 @@ +# MCP server configuration + +This document describes how to specify **which MCP server(s)** the Inspector connects to. The same configuration model is used by the Web client, CLI, and TUI. Client-specific options (e.g. web server port, CLI method to invoke) are documented in each client’s README. + +## Two ways to specify the server + +1. **Config file** – Path to an `mcp.json` (or similar) file that defines one or more servers. Optionally select one server by name (Web and CLI only; TUI uses all servers in the file). +2. **Ad-hoc** – Pass a command (and args) for stdio, or a URL (and optional transport) for SSE/Streamable HTTP, on the command line. + +You cannot mix config file and ad-hoc options in the same run (e.g. do not pass both `--config` and `--server-url`). + +--- + +## Config file + +- **Option:** `--config ` + Path to the JSON config file (relative to the current working directory or absolute). +- **Option (Web and CLI only):** `--server ` + Name of the server to use from the config file. If the file has only one server, or a server named `default-server`, it can be selected automatically and `--server` may be omitted. + +### Config file format + +The file must have an `mcpServers` object. Each key is a server name; each value is a server configuration. + +**STDIO (default)** + +```json +{ + "mcpServers": { + "my-server": { + "command": "node", + "args": ["build/index.js", "arg1"], + "env": { "KEY": "value" }, + "cwd": "/optional/working/directory" + } + } +} +``` + +- `command` (required) – Executable to run. +- `args` (optional) – Array of arguments. +- `env` (optional) – Environment variables for the server process. +- `cwd` (optional) – Working directory for the process. + +**SSE** + +```json +{ + "mcpServers": { + "my-sse": { + "type": "sse", + "url": "http://localhost:3000/sse", + "headers": { "Authorization": "Bearer token" } + } + } +} +``` + +**Streamable HTTP** + +```json +{ + "mcpServers": { + "my-http": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp", + "headers": { "X-API-Key": "value" } + } + } +} +``` + +You can use `"type": "http"` as an alias for `streamable-http`. + +--- + +## Ad-hoc (no config file) + +- **Positional arguments:** + - For stdio: `command [arg1 [arg2 ...]]` (e.g. `node build/index.js`). + - For SSE/HTTP: a single URL (e.g. `https://example.com/sse`). Transport is inferred from the URL path (`/mcp` → streamable-http, `/sse` → sse) unless overridden. +- **Options:** + - `--transport ` – `stdio`, `sse`, or `http` (http = streamable-http). Use when the URL path does not imply the transport. + - `--server-url ` – Server URL for SSE or Streamable HTTP (alternative to passing the URL as the only positional argument). + +--- + +## Overrides (apply to config file or ad-hoc) + +These options are applied on top of the server config (from file or ad-hoc): + +- **`-e `** (repeatable) – Environment variables for **stdio** servers. Merged with any `env` from the config file (CLI overrides win). +- **`--cwd `** – Working directory for **stdio** servers. Overrides `cwd` from the config file. +- **`--header <"Name: Value">`** (repeatable) – HTTP headers for **SSE** or **Streamable HTTP** servers. Merged with any `headers` from the config file. + +Examples: + +```bash +# Config file + override env +npx @modelcontextprotocol/inspector --config mcp.json --server my-server -e DEBUG=1 + +# Ad-hoc stdio with env +npx @modelcontextprotocol/inspector --cli -e KEY=value node build/index.js --method tools/list + +# Ad-hoc SSE with header +npx @modelcontextprotocol/inspector --cli --transport sse --server-url http://localhost:3000/sse --header "Authorization: Bearer token" +``` + +--- + +## Separating Inspector options from server arguments + +Use `--` to separate Inspector options from arguments that should be passed to the MCP server: + +```bash +npx @modelcontextprotocol/inspector -e FOO=bar -- node build/index.js -e server-flag +``` + +Everything after `--` is passed to the server process (for stdio) and is not interpreted by the Inspector. + +--- + +## Summary table (shared options) + +| Option / input | Description | +| -------------------- | -------------------------------------------------------- | +| `--config ` | Path to MCP config file (`mcpServers`). | +| `--server ` | Server name from config file (Web and CLI only). | +| `[target...]` | Ad-hoc: command + args (stdio) or single URL (SSE/HTTP). | +| `--transport ` | `stdio`, `sse`, or `http`. | +| `--server-url ` | URL for SSE/Streamable HTTP (ad-hoc). | +| `-e KEY=VALUE` | Env var for stdio server (repeatable). | +| `--cwd ` | Working directory for stdio server. | +| `--header "N: V"` | HTTP header for SSE/Streamable HTTP (repeatable). | + +For Web-only options (port, host, auth, etc.) see [Web Client README](../clients/web/README.md#configuring-the-web-app). For CLI-only options (e.g. `--method`, `--tool-name`) see [CLI README](../clients/cli/README.md#options). For TUI-only options (e.g. OAuth) see [TUI README](../clients/tui/README.md#options). diff --git a/docs/oauth-inspectorclient-design.md b/docs/oauth-inspectorclient-design.md deleted file mode 100644 index f1d907835..000000000 --- a/docs/oauth-inspectorclient-design.md +++ /dev/null @@ -1,1335 +0,0 @@ -# OAuth Support in InspectorClient - Design and Implementation Plan - -## Overview - -This document outlines the design and implementation plan for adding MCP OAuth 2.1 support to `InspectorClient`. The goal is to extract the general-purpose OAuth logic from the web client into the shared package and integrate it into `InspectorClient`, making OAuth available for CLI, TUI, and other InspectorClient consumers. - -**Code locations:** Paths like `client/src/lib/` and `client/src/utils/` in this document refer to the **legacy web app** (on the main/v1 tree the web app lives in `client/`; on this branch it lives in `web/`). The extraction source was the legacy client; the active app on this branch is in `web/`. - -**Important**: The web client OAuth code will remain in place and will not be modified to use the shared code at this time. Future migration options (using shared code directly, relying on InspectorClient, or a combination) should be considered in the design but not implemented. - -## Goals - -1. **Extract General-Purpose OAuth Logic**: Copy reusable OAuth components from `client/src/lib/` and `client/src/utils/` to `core/auth/` (leaving originals in place) -2. **Abstract Platform Dependencies**: Create interfaces for storage, navigation, and redirect URLs to support both browser and Node.js environments -3. **Integrate with InspectorClient**: Add OAuth support to `InspectorClient` with both direct and indirect (401-triggered) OAuth flow initiation -4. **Support All Client Identification Modes**: Support static/preregistered clients, DCR (Dynamic Client Registration), and CIMD (Client ID Metadata Documents) -5. **Enable CLI/TUI OAuth**: Provide a foundation for OAuth support in CLI and TUI applications -6. **Event-Driven Architecture**: Design OAuth flow to be notification/callback driven for client-side integration - -## Architecture - -### Current State - -The web client's OAuth implementation consists of: - -- **OAuth Client Providers** (`client/src/lib/auth.ts`): - - `InspectorOAuthClientProvider`: Standard OAuth provider for automatic flow - - `GuidedInspectorOAuthClientProvider`: Extended provider for guided flow that saves server metadata and uses guided redirect URL -- **OAuth State Machine** (`client/src/lib/oauth-state-machine.ts`): Step-by-step OAuth flow that breaks OAuth into discrete, manually-progressible steps -- **OAuth Utilities** (`client/src/utils/oauthUtils.ts`): Pure functions for parsing callbacks and generating state -- **Scope Discovery** (`client/src/lib/auth.ts`): `discoverScopes()` function -- **Storage Functions** (`client/src/lib/auth.ts`): SessionStorage-based storage helpers -- **UI Components**: - - `AuthDebugger.tsx`: Core OAuth UI providing both "Guided" (step-by-step) and "Quick" (automatic) flows - - `OAuthFlowProgress.tsx`: Visual progress indicator showing OAuth step status - - OAuth callback handlers (web-specific, not moving) - -**Note on "Guided" Mode**: The Auth Debugger (guided mode) is a **core feature** of the web client, not an optional debug tool. It provides: - -- **Guided Flow**: Manual step-by-step progression with full state visibility -- **Quick Flow**: Automatic progression through all steps -- **State Inspection**: Full visibility into OAuth state (tokens, metadata, client info, etc.) -- **Error Debugging**: Clear error messages and validation at each step - -This guided mode should be considered a core requirement for InspectorClient OAuth support, not a future enhancement. - -### Target Architecture - -``` -core/auth/ -ā”œā”€ā”€ storage.ts # Storage abstraction using Zustand with persistence -ā”œā”€ā”€ providers.ts # Abstract OAuth client provider base class -ā”œā”€ā”€ state-machine.ts # OAuth state machine (general-purpose logic) -ā”œā”€ā”€ utils.ts # General-purpose utilities -ā”œā”€ā”€ types.ts # OAuth-related types -ā”œā”€ā”€ discovery.ts # Scope discovery utilities -ā”œā”€ā”€ store.ts # Zustand store for OAuth state (vanilla, no React deps) -└── __tests__/ # Tests - -core/mcp/ -└── inspectorClient.ts # InspectorClient with OAuth integration - -core/react/ -└── auth/ # Optional: Shareable React hooks for OAuth state - └── hooks.ts # React hooks (useOAuthStore, etc.) - requires React peer dep - # Note: UI components cannot be shared between TUI (Ink) and web (DOM) - # Each client must implement its own OAuth UI components - -client/src/lib/ # Web client OAuth code (unchanged) -ā”œā”€ā”€ auth.ts -└── oauth-state-machine.ts -``` - -## Abstraction Strategy - -### 1. Storage Abstraction with Zustand - -**Storage Strategy**: Use Zustand with persistent middleware for OAuth state management. Zustand's vanilla API allows non-React usage (CLI), while React bindings enable UI integration (TUI, web client). - -**Zustand Store Structure**: - -```typescript -interface OAuthStoreState { - // Server-scoped OAuth data - servers: Record< - string, - { - tokens?: OAuthTokens; - clientInformation?: OAuthClientInformation; - preregisteredClientInformation?: OAuthClientInformation; - codeVerifier?: string; - scope?: string; - serverMetadata?: OAuthMetadata; - } - >; - - // Actions - setTokens: (serverUrl: string, tokens: OAuthTokens) => void; - getTokens: (serverUrl: string) => OAuthTokens | undefined; - clearServer: (serverUrl: string) => void; - // ... other actions -} -``` - -**Storage Implementations**: - -- **Browser**: Zustand store with `persist` middleware using `sessionStorage` adapter -- **Node.js**: Zustand store with `persist` middleware using file-based storage adapter -- **Memory**: Zustand store without persistence (for testing) - -**Storage Location for InspectorClient**: - -- Default: `~/.mcp-inspector/oauth/state.json` (single Zustand store file) -- Configurable via `InspectorClientOptions.oauth?.storagePath` - -**Benefits of Zustand**: - -- Vanilla API works without React (CLI support) -- React hooks available for UI components (TUI, web client) -- Built-in persistence middleware -- Type-safe state management -- Easier to backup/restore (one file) -- Small bundle size - -### 2. Redirect URL Abstraction - -**Interface**: - -```typescript -interface RedirectUrlProvider { - /** - * Returns the redirect URL for normal mode - */ - getRedirectUrl(): string; - - /** - * Returns the redirect URL for guided mode - */ - getDebugRedirectUrl(): string; -} -``` - -**Implementations**: - -- `BrowserRedirectUrlProvider`: - - Normal: `window.location.origin + "/oauth/callback"` - - Guided: `window.location.origin + "/oauth/callback/guided"` -- `LocalServerRedirectUrlProvider`: - - Constructor takes `port: number` parameter - - Normal: `http://localhost:${port}/oauth/callback` - - Guided: `http://localhost:${port}/oauth/callback/guided` -- `ManualRedirectUrlProvider`: - - Constructor takes `baseUrl: string` parameter - - Normal: `${baseUrl}/oauth/callback` - - Guided: `${baseUrl}/oauth/callback/guided` - -**Design Rationale**: - -- Both redirect URLs are available from the provider -- Both URLs are registered with the OAuth server during client registration (like web client) -- This allows switching between normal and guided modes without re-registering the client -- The provider's mode determines which URL is used for the current flow, but both are registered for flexibility - -### 3. Navigation Abstraction - -**Interface**: - -```typescript -interface OAuthNavigation { - redirectToAuthorization(url: URL): void | Promise; -} -``` - -**Implementations**: - -- `BrowserNavigation`: Sets `window.location.href` (for web client) -- `ConsoleNavigation`: Prints URL to console and waits for callback (for CLI/TUI) -- `CallbackNavigation`: Calls a provided callback function (for InspectorClient) - -### 4. OAuth Client Provider Abstraction - -**Base Class**: - -```typescript -abstract class BaseOAuthClientProvider implements OAuthClientProvider { - constructor( - protected serverUrl: string, - protected storage: OAuthStorage, - protected redirectUrlProvider: RedirectUrlProvider, - protected navigation: OAuthNavigation, - protected mode: "normal" | "guided" = "normal", // OAuth flow mode - ) {} - - // Abstract methods implemented by subclasses - abstract get scope(): string | undefined; - - // Returns the redirect URL for the current mode - get redirectUrl(): string { - return this.mode === "guided" - ? this.redirectUrlProvider.getDebugRedirectUrl() - : this.redirectUrlProvider.getRedirectUrl(); - } - - // Returns both redirect URIs (registered with OAuth server for flexibility) - get redirect_uris(): string[] { - return [ - this.redirectUrlProvider.getRedirectUrl(), - this.redirectUrlProvider.getDebugRedirectUrl(), - ]; - } - - abstract get clientMetadata(): OAuthClientMetadata; - - // Shared implementation for SDK interface methods - async clientInformation(): Promise { ... } - saveClientInformation(clientInformation: OAuthClientInformation): void { ... } - async tokens(): Promise { ... } - saveTokens(tokens: OAuthTokens): void { ... } - saveCodeVerifier(codeVerifier: string): void { ... } - codeVerifier(): string { ... } - clear(): void { ... } - redirectToAuthorization(authorizationUrl: URL): void { ... } - state(): string | Promise { ... } -} -``` - -**Implementations**: - -- `BrowserOAuthClientProvider`: Extends base, uses browser storage and navigation (for web client) -- `NodeOAuthClientProvider`: Extends base, uses Zustand store and console navigation (for InspectorClient/CLI/TUI) - -**Mode Selection**: - -- **Normal mode** (`mode: "normal"`): Provider uses `/oauth/callback` for the current flow -- **Guided mode** (`mode: "guided"`): Provider uses `/oauth/callback/guided` for the current flow -- Both URLs are registered with the OAuth server during client registration (allows switching modes without re-registering) -- The mode is determined when creating the provider - specify normal or debug and it "just works" -- Both callback handlers are mounted (one at `/oauth/callback`, one at `/oauth/callback/guided`) -- The handler behavior matches the provider's mode (normal handler auto-completes, debug handler shows code) - -**Client Identification Modes**: - -- **Static/Preregistered**: Uses `clientId` and optional `clientSecret` from config -- **DCR (Dynamic Client Registration)**: Falls back to DCR if no static client provided -- **CIMD (Client ID Metadata Documents)**: Uses `clientMetadataUrl` from config to enable URL-based client IDs (SEP-991) - -## Module Structure - -### `core/auth/store.ts` - -**Exports** (vanilla-only, no React dependencies): - -- `createOAuthStore()` - Factory function to create Zustand store -- `getOAuthStore()` - Vanilla API for accessing store (no React dependency) - -**Note**: React hooks (if needed) would be in `core/react/auth/hooks.ts` as an optional export that requires React as a peer dependency. - -**Store Implementation**: - -- Uses Zustand's `create` function with `persist` middleware -- Browser: Persists to `sessionStorage` via Zustand's `persist` middleware -- Node.js: Persists to file via custom storage adapter for Zustand's `persist` middleware -- Memory: No persistence (for testing) - -**Storage Adapter for Node.js**: - -- Custom Zustand storage adapter that uses Node.js `fs/promises` -- Stores single JSON file: `~/.mcp-inspector/oauth/state.json` -- Handles file creation, reading, and writing atomically - -### `core/auth/providers.ts` - -**Exports**: - -- `BaseOAuthClientProvider` abstract class -- `BrowserOAuthClientProvider` class (for web client, uses sessionStorage directly) -- `NodeOAuthClientProvider` class (for InspectorClient/CLI/TUI, uses Zustand store) - -**Key Methods**: - -- All SDK `OAuthClientProvider` interface methods -- Server-specific state management via Zustand store -- Token and client information management -- Support for `clientMetadataUrl` for CIMD mode - -### `core/auth/state-machine.ts` - -**Exports**: - -- `OAuthStateMachine` class -- `oauthTransitions` object (state transition definitions) -- `StateMachineContext` interface -- `StateTransition` interface - -**Changes from Current Implementation**: - -- Accepts abstract `OAuthClientProvider` instead of `DebugInspectorOAuthClientProvider` -- Removes web-specific dependencies (sessionStorage, window.location) -- General-purpose state transition logic - -### `core/auth/utils.ts` - -**Exports**: - -- `parseOAuthCallbackParams(location: string): CallbackParams` - Pure function -- `generateOAuthErrorDescription(params: CallbackParams): string` - Pure function -- `generateOAuthState(): string` - Uses `globalThis.crypto` or Node.js `crypto` module - -**Changes from Current Implementation**: - -- `generateOAuthState()` checks for `globalThis.crypto` first (browser), falls back to Node.js `crypto.randomBytes()` - -### `core/auth/types.ts` - -**Exports**: - -- `CallbackParams` type (from `oauthUtils.ts`) -- Re-export SDK OAuth types as needed - -### `core/auth/discovery.ts` - -**Exports**: - -- `discoverScopes(serverUrl: string, resourceMetadata?: OAuthProtectedResourceMetadata): Promise` - -**Note**: This is already general-purpose (uses only SDK functions), just needs to be moved. - -### `core/react/auth/` (Optional - Shareable React Hooks Only) - -**What Can Be Shared**: - -- `hooks.ts` - React hooks for accessing OAuth state: - - `useOAuthStore()` - Hook to access Zustand OAuth store - - `useOAuthTokens()` - Hook to get current OAuth tokens - - `useOAuthState()` - Hook to get current OAuth state machine state - - These hooks are pure logic - no rendering, so they work with both Ink (TUI) and DOM (web) - -**What Cannot Be Shared**: - -- **UI Components** (`.tsx` files with visual rendering) cannot be shared because: - - TUI uses **Ink** (terminal rendering) with components like ``, ``, etc. - - Web client uses **DOM** (browser rendering) with components like `
`, ``, etc. - - They have completely different rendering targets, styling systems, and component APIs -- Each client must implement its own OAuth UI components: - - TUI: `tui/src/components/OAuthFlowProgress.tsx` (using Ink components) - - Web: `client/src/components/OAuthFlowProgress.tsx` (using DOM/HTML components) - -## OAuth Guided Mode (Core Feature) - -### What is the Auth Debugger? - -The "Auth Debugger" (guided mode) in the web client is **not** an optional debug tool - it's a **core feature** that provides two modes of OAuth flow: - -1. **Guided Flow** (Step-by-Step): - - Breaks OAuth into discrete, manually-progressible steps - - User clicks "Next" to advance through each step - - Full state visibility at each step (metadata, client info, tokens, etc.) - - Allows inspection and debugging of OAuth flow - - Steps: `metadata_discovery` → `client_registration` → `authorization_redirect` → `authorization_code` → `token_request` → `complete` - -2. **Quick Flow** (Automatic): - - Automatically progresses through all OAuth steps - - Still uses the state machine internally - - Redirects to authorization URL automatically - - Returns to callback with authorization code - -### How It Works - -**Components**: - -- **`OAuthStateMachine`**: Manages step-by-step progression through OAuth flow -- **`GuidedInspectorOAuthClientProvider`** (shared: `GuidedNodeOAuthClientProvider`): Extended provider that: - - Uses guided redirect URL (`/oauth/callback/guided` instead of `/oauth/callback`) - - Saves server OAuth metadata to storage for UI display - - Provides `getServerMetadata()` and `saveServerMetadata()` methods -- **`AuthGuidedState`**: Comprehensive state object tracking all OAuth data: - - Current step (`oauthStep`) - - OAuth metadata, client info, tokens - - Authorization URL, code, errors - - Resource metadata, validation errors - -**State Machine Steps** (Detailed): - -1. **`metadata_discovery`**: **RFC 8414 Discovery** - Client discovers authorization server metadata - - Always client-initiated (never uses server-provided metadata from MCP capabilities) - - Calls SDK `discoverOAuthProtectedResourceMetadata()` which makes HTTP request to `/.well-known/oauth-protected-resource` - - Calls SDK `discoverAuthorizationServerMetadata()` which makes HTTP request to `/.well-known/oauth-authorization-server` - - The SDK methods handle the actual HTTP requests to well-known endpoints - - Discovery Flow: - 1. Attempts to discover resource metadata from the MCP server URL - 2. If resource metadata contains `authorization_servers`, uses the first one; otherwise defaults to MCP server base URL - 3. Discovers OAuth authorization server metadata from the determined authorization server URL - 4. Uses discovered metadata for client registration and authorization -2. **`client_registration`**: **Registers client** (static, DCR, or CIMD) - - First tries preregistered/static client information (from config) - - Falls back to Dynamic Client Registration (DCR) if no static client available - - If `clientMetadataUrl` is provided, uses CIMD (Client ID Metadata Documents) mode - - Implementation pattern: - ```typescript - // Try Static client first, with DCR as fallback - let fullInformation = await context.provider.clientInformation(); - if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); - context.provider.saveClientInformation(fullInformation); - } - ``` -3. **`authorization_redirect`**: Generates authorization URL with PKCE - - Calls SDK `startAuthorization()` which generates PKCE code challenge - - Builds authorization URL with all required parameters - - Saves code verifier for later token exchange -4. **`authorization_code`**: User provides authorization code (manual entry or callback) - - Validates authorization code input - - In guided mode, waits for user to enter code or receive via callback -5. **`token_request`**: Exchanges code for tokens - - Calls SDK `exchangeAuthorization()` with authorization code and code verifier - - Receives OAuth tokens (access_token, refresh_token, etc.) - - Saves tokens to storage -6. **`complete`**: Final state with tokens - - OAuth flow complete - - Tokens available for use in requests - -**Why It's Core**: - -- Provides transparency into OAuth flow (critical for debugging) -- Allows manual intervention at each step -- Shows full OAuth state (metadata, client info, tokens) -- Essential for troubleshooting OAuth issues -- Users expect this level of visibility in a developer tool - -**InspectorClient Integration**: - -- InspectorClient should support both automatic and guided modes -- Guided mode should expose state machine state via events/API -- CLI/TUI can use guided mode for step-by-step OAuth flow -- State machine should be part of initial implementation, not a future enhancement - -### OAuth Mode Implementation Details - -#### DCR (Dynamic Client Registration) Support - -**Behavior**: - -- āœ… Tries preregistered/static client info first (from Zustand store, set via config) -- āœ… Falls back to DCR via SDK `registerClient()` if no static client is found -- āœ… Client information is stored in Zustand store after registration - -**Storage**: - -- Preregistered clients: Stored in Zustand store as `preregisteredClientInformation` -- Dynamically registered clients: Stored in Zustand store as `clientInformation` -- The `clientInformation()` method checks preregistered first, then dynamic - -#### RFC 8414 Authorization Server Metadata Discovery - -**Behavior**: - -- āœ… Always initiates discovery client-side (never uses server-provided metadata from MCP capabilities) -- āœ… Discovers resource metadata from `/.well-known/oauth-protected-resource` via SDK `discoverOAuthProtectedResourceMetadata()` -- āœ… Discovers OAuth authorization server metadata from `/.well-known/oauth-authorization-server` via SDK `discoverAuthorizationServerMetadata()` -- āœ… No code path uses server-provided metadata from MCP server capabilities -- āœ… SDK methods handle the actual HTTP requests to well-known endpoints - -**Discovery Flow**: - -1. Attempts to discover resource metadata from the MCP server URL -2. If resource metadata contains `authorization_servers`, uses the first one; otherwise defaults to MCP server base URL -3. Discovers OAuth authorization server metadata from the determined authorization server URL -4. Uses discovered metadata for client registration and authorization - -**Note**: This is RFC 8414 discovery (client discovering server endpoints), not CIMD. CIMD is a separate concept (server discovering client information via URL-based client IDs). - -#### CIMD (Client ID Metadata Documents) Support - -**Status**: āœ… **Supported** (new in InspectorClient, not in current web client) - -**What CIMD Is**: - -- CIMD (Client ID Metadata Documents, SEP-991) is the DCR replacement introduced in the November 2025 MCP spec -- The client publishes its metadata at a URL (e.g., `https://inspector.app/.well-known/oauth-client-metadata`) -- That URL becomes the `client_id` (instead of a random string from DCR) -- The authorization server fetches that URL to discover client information (name, redirect_uris, etc.) -- This is "reverse discovery" - the server discovers the client, not the client discovering the server - -**How InspectorClient Supports CIMD**: - -- User provides `clientMetadataUrl` in OAuth config -- `NodeOAuthClientProvider` sets `clientMetadataUrl` in `clientMetadata` -- SDK checks for CIMD support and uses URL-based client ID if supported -- Falls back to DCR if authorization server doesn't support CIMD - -**What's Required for CIMD**: - -1. Publish client metadata at a publicly accessible URL -2. Set `clientMetadataUrl` in OAuth config -3. The authorization server must support `client_id_metadata_document_supported: true` - -### OAuth Flow Descriptions - -#### Automatic Flow (Quick Mode) - -1. **Configuration**: User provides OAuth config (clientId, clientSecret, scope, clientMetadataUrl) via `InspectorClientOptions` or `setOAuthConfig()` -2. **Storage**: Config saved to Zustand store as `preregisteredClientInformation` (if static client provided) -3. **Initiation**: User calls `authenticate()` (or `authenticateGuided()` for guided mode). We do not auto-initiate on 401; callers authenticate first, then connect. -4. **SDK Handles**: - - Authorization server metadata discovery (RFC 8414 - always client-initiated) - - Client registration (static, DCR, or CIMD based on config) - - Authorization redirect (generates PKCE challenge, builds authorization URL) -5. **Navigation**: Authorization URL dispatched via `oauthAuthorizationRequired` event -6. **User Action**: User navigates to authorization URL (via callback handler, browser open, or manual navigation) -7. **Callback**: Authorization server redirects to callback URL with authorization code -8. **Processing**: User provides authorization code via `completeOAuthFlow()` -9. **Token Exchange**: SDK exchanges code for tokens (using stored code verifier) -10. **Storage**: Tokens saved to Zustand store -11. **Connect**: User calls `connect()`. Transport is created with `authProvider` (tokens in storage). SDK injects tokens and handles 401 (auth, retry) inside the transport. We do not retry connect or requests after OAuth; the transport does. - -#### Guided Flow (Step-by-Step Mode) - -1. **Initiation**: User calls `authenticateGuided()` to begin guided flow -2. **State Machine**: `OAuthStateMachine` executes steps manually -3. **Step Control**: Each step can be viewed and manually progressed via `proceedOAuthStep()` -4. **State Visibility**: Full OAuth state available via `getOAuthState()` and `oauthStepChange` events -5. **Events**: `oauthStepChange` event dispatched on each step transition with current state - - Event detail includes: `step`, `previousStep`, and `state` (partial state update) - - UX layer can listen to update UI, enable/disable buttons, show step-specific information -6. **Authorization**: Authorization URL generated and dispatched via `oauthAuthorizationRequired` event -7. **Code Entry**: Authorization code can be entered manually or received via callback -8. **Completion**: `oauthComplete` event dispatched, full state visible, tokens stored in Zustand store - -## InspectorClient Integration - -### New Options - -```typescript -export interface InspectorClientOptions { - // ... existing options ... - - /** - * OAuth configuration - */ - oauth?: { - /** - * Preregistered client ID (optional, will use DCR if not provided) - * If clientMetadataUrl is provided, this is ignored (CIMD mode) - */ - clientId?: string; - - /** - * Preregistered client secret (optional, only if client requires secret) - * If clientMetadataUrl is provided, this is ignored (CIMD mode) - */ - clientSecret?: string; - - /** - * Client metadata URL for CIMD (Client ID Metadata Documents) mode - * If provided, enables URL-based client IDs (SEP-991) - * The URL becomes the client_id, and the authorization server fetches it to discover client metadata - */ - clientMetadataUrl?: string; - - /** - * OAuth scope (optional, will be discovered if not provided) - */ - scope?: string; - - /** - * Redirect URL for OAuth callback (required for OAuth flow) - * For CLI/TUI, this should be a local server URL or manual callback URL - */ - redirectUrl?: string; - - /** - * Storage path for OAuth data (default: ~/.mcp-inspector/oauth/) - */ - storagePath?: string; - }; -} -``` - -### New Methods - -```typescript -class InspectorClient { - // OAuth configuration - setOAuthConfig(config: { - clientId?: string; - clientSecret?: string; - clientMetadataUrl?: string; // For CIMD mode - scope?: string; - redirectUrl?: string; - }): void; - - // OAuth flow initiation (normal mode) - /** - * Initiates OAuth flow (user-initiated or 401-triggered). Both paths use this method. - * Returns the authorization URL. Dispatches 'oauthAuthorizationRequired' event. - */ - async authenticate(): Promise; - - /** - * Completes OAuth flow with authorization code - * @param authorizationCode - Authorization code from OAuth callback - * Dispatches 'oauthComplete' event on success - * Dispatches 'oauthError' event on failure - */ - async completeOAuthFlow(authorizationCode: string): Promise; - - // OAuth state management - /** - * Gets current OAuth tokens (if authorized) - */ - getOAuthTokens(): OAuthTokens | undefined; - - /** - * Clears OAuth tokens and client information - */ - clearOAuthTokens(): void; - - /** - * Checks if client is currently OAuth authorized - */ - isOAuthAuthorized(): boolean; - - /** - * Initiates OAuth flow in guided mode (step-by-step, state machine). - * Returns the authorization URL. Dispatches 'oauthAuthorizationRequired' and 'oauthStepChange' events. - */ - async authenticateGuided(): Promise; - - // Guided mode state management - /** - * Get current OAuth state machine state (for guided mode) - * Returns undefined if not in guided mode - */ - getOAuthState(): AuthGuidedState | undefined; - - /** - * Get current OAuth step (for guided mode) - * Returns undefined if not in guided mode - */ - getOAuthStep(): OAuthStep | undefined; - - /** - * Manually progress to next step in guided OAuth flow - * Only works when in guided mode - * Dispatches 'oauthStepChange' event on step transition - */ - async proceedOAuthStep(): Promise; -} -``` - -### OAuth Flow Initiation - -**Two Modes of Initiation**: - -1. **Normal Mode** (User-Initiated): - - User calls `client.authenticate()` explicitly - - Uses SDK's `auth()` function internally - - Returns authorization URL - - Dispatches `oauthAuthorizationRequired` event - - Client-side (CLI/TUI) listens for events and handles navigation - - User completes OAuth (e.g. via callback), then calls `completeOAuthFlow(code)`, then `connect()`. The transport uses `authProvider` to inject tokens; the SDK handles 401 (auth, retry) internally. We do not automatically retry connect or requests after OAuth. - -2. **Guided Mode** (User-Initiated): - - User calls `client.authenticateGuided()` explicitly - - Uses state machine for step-by-step control - - Dispatches `oauthStepChange` events as flow progresses - - Returns authorization URL - - Dispatches `oauthAuthorizationRequired` event - - Client-side listens for events and handles navigation - - Same flow as normal: complete OAuth, then `connect()`. - -**Event-Driven Architecture**: - -```typescript -// InspectorClient dispatches events for OAuth flow -this.dispatchTypedEvent("oauthAuthorizationRequired", { - url: authorizationUrl, -}); - -this.dispatchTypedEvent("oauthComplete", { tokens }); -this.dispatchTypedEvent("oauthError", { error }); - -// InspectorClient dispatches events for guided flow -this.dispatchTypedEvent("oauthStepChange", { - step: OAuthStep, - previousStep?: OAuthStep, - state: Partial -}); - -// Client-side (CLI/TUI) listens for events -client.addEventListener("oauthAuthorizationRequired", (event) => { - const { url } = event.detail; - // Handle navigation (print URL, open browser, etc.) - // Wait for user to provide authorization code - // Call client.completeOAuthFlow(code) -}); - -// For guided mode, listen for step changes -client.addEventListener("oauthStepChange", (event) => { - const { step, state } = event.detail; - // Update UI to show current step and state - // Enable/disable "Continue" button based on step -}); -``` - -**Event-Driven Architecture**: - -- InspectorClient dispatches `oauthAuthorizationRequired` events -- Callers are responsible for registering event listeners to handle the authorization URL -- CLI/TUI applications should register listeners to display the URL (e.g., print to console, show in UI) -- No default console output - callers must explicitly handle events - -**401 Error Handling (legacy; see authProvider migration below)**: - -InspectorClient previously detected 401 in `connect()` and request methods, called `authenticate()`, stored a pending request, and retried after OAuth. This custom logic has been **removed**. 401 handling is now delegated to the SDK transport via `authProvider`. - -### Token Injection and authProvider (Current Implementation) - -**Integration Point**: For HTTP-based transports (SSE, streamable-http), we pass an **`authProvider`** (`OAuthClientProvider`) into `createTransport`. The SDK injects tokens and handles 401 via the provider; we do not manually add `Authorization` headers or detect 401. - -- **Transport creation**: All transport creation happens in **`connect()`** (single place for create, wrap, attach). When OAuth is configured, we create a provider via `createOAuthProvider("normal" | "guided")` and pass it as `authProvider` to `createTransport`; the provider is created async there. -- **Flow**: Callers **authenticate first**, then connect. Run `authenticate()` or `authenticateGuided()`, complete OAuth with `completeOAuthFlow(code)`, then call `connect()`. The transport uses `authProvider` to inject tokens; the SDK handles 401 (auth, retry) inside the transport. -- **No connect-time 401 retry**: We do not catch 401 on `connect()` or retry. If `connect()` is called without tokens, the transport/SDK may throw (e.g. `Unauthorized`). Callers must run `authenticate()` (or guided flow), then retry `connect()`. -- **Request methods**: We no longer wrap `listTools`, `listResources`, etc. with 401 detection or retry. The transport handles 401 for all requests when `authProvider` is used. -- **Removed**: `getOAuthToken` callback, `createOAuthFetchWrapper`, `is401Error`, `handleRequestWithOAuth`, `pendingOAuthRequest`, and connect-time 401 catch block. - -## Implementation Plan - -### Phase 1: Extract and Abstract OAuth Components - -**Goal**: Copy general-purpose OAuth code to shared package with abstractions (leaving web client code unchanged) - -1. **Create Zustand Store** (`core/auth/store.ts`) - - Install Zustand dependency (with persist middleware support) - - Create `createOAuthStore()` factory function - - Implement browser storage adapter (sessionStorage) for Zustand persist - - Implement file storage adapter (Node.js fs) for Zustand persist - - Export vanilla API (`getOAuthStore()`) only (no React dependencies) - - React hooks (if needed) would be in separate `core/react/auth/hooks.ts` file - - Add `getServerSpecificKey()` helper - -2. **Create Redirect URL Abstraction** (`core/auth/providers.ts` - part 1) - - Define `RedirectUrlProvider` interface with `getRedirectUrl()` and `getDebugRedirectUrl()` methods - - Implement `BrowserRedirectUrlProvider` (returns normal and debug URLs based on `window.location.origin`) - - Implement `LocalServerRedirectUrlProvider` (constructor takes `port`, returns normal and debug URLs) - - Implement `ManualRedirectUrlProvider` (constructor takes `baseUrl`, returns normal and debug URLs) - - **Key**: Both URLs are available, both are registered with OAuth server, mode determines which is used for current flow - -3. **Create Navigation Abstraction** (`core/auth/providers.ts` - part 2) - - Define `OAuthNavigation` interface - - Implement `BrowserNavigation` - - Implement `ConsoleNavigation` - - Implement `CallbackNavigation` - -4. **Create Base OAuth Provider** (`core/auth/providers.ts` - part 3) - - Create `BaseOAuthClientProvider` abstract class - - Implement shared SDK interface methods - - Move storage, redirect URL, and navigation logic to base class - - Add support for `clientMetadataUrl` (CIMD mode) - -5. **Create Provider Implementations** (`core/auth/providers.ts` - part 4) - - Create `BrowserOAuthClientProvider` (extends base, uses sessionStorage directly - for web client reference) - - Create `NodeOAuthClientProvider` (extends base, uses Zustand store - for InspectorClient/CLI/TUI) - - Support all three client identification modes: static, DCR, CIMD - -6. **Copy OAuth Utilities** (`core/auth/utils.ts`) - - Copy `parseOAuthCallbackParams()` from `client/src/utils/oauthUtils.ts` - - Copy `generateOAuthErrorDescription()` from `client/src/utils/oauthUtils.ts` - - Adapt `generateOAuthState()` to support both browser and Node.js - -7. **Copy OAuth State Machine** (`core/auth/state-machine.ts`) - - Copy `OAuthStateMachine` class from `client/src/lib/oauth-state-machine.ts` - - Copy `oauthTransitions` object - - Update to use abstract `OAuthClientProvider` instead of `DebugInspectorOAuthClientProvider` - -8. **Copy Scope Discovery** (`core/auth/discovery.ts`) - - Copy `discoverScopes()` from `client/src/lib/auth.ts` - -9. **Create Types Module** (`core/auth/types.ts`) - - Copy `CallbackParams` type from `client/src/utils/oauthUtils.ts` - - Re-export SDK OAuth types as needed - -### Phase 2: (Skipped - Web Client Unchanged) - -**Note**: Web client OAuth code remains in place and is not modified at this time. Future migration options: - -- Option A: Web client uses shared auth code directly -- Option B: Web client relies on InspectorClient for OAuth -- Option C: Hybrid approach (some components use shared code, others use InspectorClient) - -These options should be considered in the design but not implemented now. - -### Phase 3: Integrate OAuth into InspectorClient - -**Goal**: Add OAuth support to InspectorClient with both direct and indirect initiation - -1. **Add OAuth Options to InspectorClientOptions** - - Add `oauth` configuration option with support for `clientMetadataUrl` (CIMD) - - Define OAuth configuration interface - - Support all three client identification modes - -2. **Add OAuth Provider to InspectorClient** - - Store OAuth config - - Create `NodeOAuthClientProvider` instances on-demand based on mode (lazy initialization) - - Normal mode provider created by default (for automatic flows) - - Guided mode provider created when `authenticateGuided()` is called - - Initialize Zustand store for OAuth state - - **Important**: Both redirect URLs are registered with OAuth server (allows switching modes without re-registering) - - Both callback handlers are mounted (normal at `/oauth/callback`, guided at `/oauth/callback/guided`) - - The provider's mode determines which URL is used for the current flow - -3. **Implement OAuth Methods** - - Implement `setOAuthConfig()` (supports clientMetadataUrl for CIMD) - - Implement `authenticate()` (direct and 401-triggered initiation, uses normal-mode provider) - - Implement `completeOAuthFlow()` - - Implement `getOAuthTokens()` - - Implement `clearOAuthTokens()` - - Implement `isOAuthAuthorized()` - - Implement guided mode state management methods: - - `getOAuthState()` - Get current OAuth state machine state (returns undefined if not in guided mode) - - `getOAuthStep()` - Get current OAuth step (returns undefined if not in guided mode) - - `proceedOAuthStep()` - Manually progress to next step (only works in guided mode, dispatches `oauthStepChange` event) - - **Note**: Guided mode is initiated via `authenticateGuided()`, which creates a provider with `mode="guided"` and initiates the flow - - **Note**: When creating `NodeOAuthClientProvider`, pass the `mode` parameter. Both redirect URLs are registered, but the provider uses the URL matching its mode for the current flow. - -4. **~~Add 401 Error Detection~~** (removed in authProvider migration) - - We no longer use `is401Error()` or detect 401 in connect/request methods. The transport handles 401 via `authProvider`. - -5. **Add OAuth Flow Initiation (User-Initiated Only)** - - User calls `authenticate()` or `authenticateGuided()` first, then `completeOAuthFlow(code)`, then `connect()`. We do not catch 401 or retry; the transport uses `authProvider` for token injection and 401 handling. - -6. **Add Guided Mode** - - Implement `authenticateGuided()` for step-by-step OAuth flow - - Create provider with `mode="guided"` when `authenticateGuided()` is called - - Dispatch `oauthAuthorizationRequired` and `oauthStepChange` events as state machine progresses - -7. **Add Token Injection (via authProvider)** - - For HTTP-based transports with OAuth, pass `authProvider` into `createTransport`. The SDK injects tokens and handles 401. We do not manually add `Authorization` headers. All transport creation happens in `connect()`. - - Refresh tokens if expired (future enhancement) – handled by SDK/authProvider when supported. - -8. **Add OAuth Events** - - Add `oauthAuthorizationRequired` event (dispatches authorization URL, mode, optional originalError) - - Add `oauthComplete` event (dispatches tokens) - - Add `oauthError` event (dispatches error) - - Add `oauthStepChange` event (dispatches step, previousStep, state) - for guided mode - - All events are event-driven for client-side integration - - Callers must register event listeners to handle `oauthAuthorizationRequired` events - -### Phase 4: Testing - -**Goal**: Comprehensive testing of OAuth support - -1. **Unit Tests for Shared OAuth Components** - - Test storage adapters (Browser, Memory, File) - - Test redirect URL providers - - Test navigation handlers - - Test OAuth utilities - - Test state machine transitions - - Test scope discovery - -2. **Integration Tests for InspectorClient OAuth** - - Test OAuth configuration - - Test 401 error detection and OAuth flow initiation - - Test token injection in HTTP transports - - Test OAuth flow completion - - Test token storage and retrieval - - Test OAuth error handling - -3. **End-to-End Tests with OAuth Test Server** - - Test full OAuth flow with test server (see "OAuth Test Server Infrastructure" below) - - Test static/preregistered client mode - - Test DCR (Dynamic Client Registration) mode - - Test CIMD (Client ID Metadata Documents) mode - - Test scope discovery - - Test token refresh (if supported) - - Test OAuth cleanup - - Test 401 error handling and automatic retry - -4. **Web Client Regression Tests** - - Verify all existing OAuth tests still pass - - Test normal OAuth flow - - Test debug OAuth flow - - Test OAuth callback handling - -## OAuth Test Server Infrastructure - -### Overview - -OAuth testing requires a full OAuth 2.1 authorization server that can: - -- Return 401 errors on MCP requests (to trigger OAuth flow initiation) -- Serve OAuth metadata endpoints (RFC 8414 discovery) -- Handle all three client identification modes (static, DCR, CIMD) -- Support authorization and token exchange flows -- Verify Bearer tokens on protected MCP endpoints - -**Decision**: Use **better-auth** (or similar third-party OAuth library) for the test server rather than implementing OAuth from scratch. This provides: - -- Faster implementation -- Production-like OAuth behavior -- Better security coverage -- Reduced maintenance burden - -### Integration with Existing Test Infrastructure - -The OAuth test server will integrate with the existing `composable-test-server.ts` infrastructure: - -1. **Extend `ServerConfig` Interface** (`core/test/composable-test-server.ts`): - - ```typescript - export interface ServerConfig { - // ... existing config ... - oauth?: { - /** - * Whether OAuth is enabled for this test server - */ - enabled: boolean; - - /** - * OAuth authorization server issuer URL - * Used for metadata endpoints and token issuance - */ - issuerUrl: URL; - - /** - * List of scopes supported by this authorization server - */ - scopesSupported?: string[]; - - /** - * If true, MCP endpoints require valid Bearer token - * Returns 401 Unauthorized if token is missing or invalid - */ - requireAuth?: boolean; - - /** - * Static/preregistered clients for testing - * These clients are pre-configured and don't require DCR - */ - staticClients?: Array<{ - clientId: string; - clientSecret?: string; - redirectUris?: string[]; - }>; - - /** - * Whether to support Dynamic Client Registration (DCR) - * If true, exposes /register endpoint for client registration - */ - supportDCR?: boolean; - - /** - * Whether to support CIMD (Client ID Metadata Documents) - * If true, server will fetch client metadata from clientMetadataUrl - */ - supportCIMD?: boolean; - - /** - * Token expiration time in seconds (default: 3600) - */ - tokenExpirationSeconds?: number; - - /** - * Whether to support refresh tokens (default: true) - */ - supportRefreshTokens?: boolean; - }; - } - ``` - -2. **Extend `TestServerHttp`** (`core/test/test-server-http.ts`): - - Install better-auth OAuth router on Express app (before MCP routes) - - Add Bearer token verification middleware on `/mcp` endpoint - - Return 401 if `requireAuth: true` and no valid token present - - Serve OAuth metadata endpoints: - - `/.well-known/oauth-authorization-server` (RFC 8414) - - `/.well-known/oauth-protected-resource` (RFC 8414) - - Handle client registration endpoint (`/register`) if DCR enabled - - Handle authorization endpoint (`/authorize`) - see "Authorization Endpoint" below - - Handle token endpoint (`/token`) - - Handle token revocation endpoint (`/revoke`) if supported - - **Authorization Endpoint Implementation**: - - better-auth provides the authorization endpoint (`/oauth/authorize` or similar) - - For automated testing, create a **test authorization page** that: - - Accepts authorization requests (client_id, redirect_uri, scope, state, code_challenge) - - Automatically approves the request (no user interaction required) - - Redirects to `redirect_uri` with authorization code and state - - This allows tests to programmatically complete the OAuth flow without browser automation - - For true E2E tests requiring user interaction, better-auth's built-in UI can be used - -3. **Create OAuth Test Fixtures** (`core/test/test-server-fixtures.ts`): - - ```typescript - /** - * Creates a test server configuration with OAuth enabled - */ - export function createOAuthTestServerConfig(options: { - requireAuth?: boolean; - scopesSupported?: string[]; - staticClients?: Array<{ clientId: string; clientSecret?: string }>; - supportDCR?: boolean; - supportCIMD?: boolean; - }): ServerConfig; - - /** - * Creates OAuth configuration for InspectorClient tests - */ - export function createOAuthClientConfig(options: { - mode: "static" | "dcr" | "cimd"; - clientId?: string; - clientSecret?: string; - clientMetadataUrl?: string; - redirectUrl: string; - }): InspectorClientOptions["oauth"]; - - /** - * Helper function to programmatically complete OAuth authorization - * Makes HTTP GET request to authorization URL and extracts authorization code - * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event - * @returns Authorization code extracted from redirect URL - */ - export async function completeOAuthAuthorization( - authorizationUrl: URL, - ): Promise; - ``` - -### Authorization Endpoint and Test Flow - -**Authorization Endpoint**: -The test server will provide a functioning OAuth authorization endpoint (via better-auth) that: - -1. **Accepts Authorization Requests**: The endpoint receives authorization requests with: - - `client_id`: The OAuth client identifier - - `redirect_uri`: Where to redirect after approval - - `scope`: Requested OAuth scopes - - `state`: CSRF protection state parameter - - `code_challenge`: PKCE code challenge - - `response_type`: Always "code" for authorization code flow - -2. **Test Authorization Page**: For automated testing, the test server will provide a simple authorization page that: - - Automatically approves all authorization requests (no user interaction) - - Generates an authorization code - - Redirects to `redirect_uri` with the code and state parameter - - This allows tests to programmatically complete OAuth without browser automation - -3. **Programmatic Authorization Helper**: Tests can use a helper function to: - - Extract authorization URL from `oauthAuthorizationRequired` event - - Make HTTP GET request to authorization URL - - Parse redirect response to extract authorization code - - Call `client.completeOAuthFlow(authorizationCode)` to complete the flow - -**Example Test Flow**: - -```typescript -// 1. Configure test server with OAuth enabled -const server = new TestServerHttp({ - ...getDefaultServerConfig(), - oauth: { - enabled: true, - requireAuth: true, - staticClients: [{ clientId: "test-client", clientSecret: "test-secret" }], - }, -}); -await server.start(); - -// 2. Configure InspectorClient with OAuth -const client = new InspectorClient({ - serverUrl: server.url, - oauth: { - clientId: "test-client", - clientSecret: "test-secret", - redirectUrl: "http://localhost:3000/oauth/callback", - }, -}); - -// 3. Listen for OAuth authorization required event -let authUrl: URL | null = null; -client.addEventListener("oauthAuthorizationRequired", (event) => { - authUrl = event.detail.url; -}); - -// 4. Make MCP request (triggers 401, then OAuth flow) -try { - await client.listTools(); -} catch (error) { - // Expected: 401 error triggers OAuth flow -} - -// 5. Programmatically complete authorization -if (authUrl) { - // Make GET request to authorization URL (auto-approves in test server) - const response = await fetch(authUrl.toString(), { redirect: "manual" }); - const redirectUrl = response.headers.get("location"); - - // Extract authorization code from redirect URL - const redirectUrlObj = new URL(redirectUrl!); - const code = redirectUrlObj.searchParams.get("code"); - - // Complete OAuth flow - await client.completeOAuthFlow(code!); - - // 6. Retry original request (should succeed with token) - const tools = await client.listTools(); - expect(tools).toBeDefined(); -} -``` - -### Test Scenarios - -**Static Client Mode**: - -- Configure test server with `staticClients` -- Configure InspectorClient with matching `clientId`/`clientSecret` -- Test full OAuth flow without DCR -- Verify authorization endpoint auto-approves and redirects with code - -**DCR Mode**: - -- Configure test server with `supportDCR: true` -- Configure InspectorClient without `clientId` (triggers DCR) -- Test client registration, then full OAuth flow -- Verify DCR endpoint registers client, then authorization flow proceeds - -**CIMD Mode**: - -- Configure test server with `supportCIMD: true` -- Configure InspectorClient with `clientMetadataUrl` -- Test server fetches client metadata from URL -- Test full OAuth flow with URL-based client ID - -**401 Error Handling**: - -- Configure test server with `requireAuth: true` -- Make MCP request without token → expect 401 -- Verify `oauthAuthorizationRequired` event dispatched -- Programmatically complete OAuth flow (auto-approve authorization) -- Verify original request automatically retried with token - -**Token Verification**: - -- Configure test server with `requireAuth: true` -- Make MCP request with valid Bearer token → expect success -- Make MCP request with invalid/expired token → expect 401 - -### Implementation Steps - -1. **Install better-auth dependency** (or chosen OAuth library) - - Add to `core/package.json` as dev dependency - -2. **Create OAuth test server wrapper** (`core/test/oauth-test-server.ts`) - - Wrap better-auth configuration - - Integrate with Express app in `TestServerHttp` - - Handle static clients, DCR, CIMD modes - - Create test authorization page that auto-approves requests - - Provide helper function to programmatically extract authorization code from redirect - -3. **Extend `ServerConfig` interface** - - Add `oauth` configuration option - - Update `createMcpServer()` to handle OAuth config - -4. **Extend `TestServerHttp`** - - Install OAuth router before MCP routes - - Add Bearer token middleware - - Return 401 when `requireAuth: true` and token invalid - -5. **Create test fixtures** - - `createOAuthTestServerConfig()` - - `createOAuthClientConfig()` - - Helper functions for common OAuth test scenarios - -6. **Write integration tests** - - Test each client identification mode - - Test 401 error handling - - Test token verification - - Test full OAuth flow end-to-end - -## Storage Strategy - -### InspectorClient Storage (Node.js) - Zustand with File Persistence - -**Location**: `~/.mcp-inspector/oauth/state.json` (single Zustand store file) - -**Storage Format**: - -```json -{ - "state": { - "servers": { - "https://example.com/mcp": { - "tokens": { "access_token": "...", "refresh_token": "..." }, - "clientInformation": { "client_id": "...", ... }, - "preregisteredClientInformation": { "client_id": "...", ... }, - "codeVerifier": "...", - "scope": "...", - "serverMetadata": { ... } - } - } - }, - "version": 0 -} -``` - -**Benefits**: - -- Single file for all OAuth state across all servers -- Zustand handles serialization/deserialization automatically -- Atomic writes via Zustand's persist middleware -- Type-safe state management -- Easier to backup/restore (one file) - -**Security Considerations**: - -- File contains sensitive data (tokens, secrets) -- Use restrictive file permissions (600) for state.json -- Consider encryption for production use (future enhancement) - -### Web Client Storage (Browser) - -**Location**: Browser `sessionStorage` (unchanged - web client code not modified) - -**Key Format**: `[${serverUrl}] ${baseKey}` (unchanged) - -## Navigation Strategy - -### InspectorClient Navigation - -**Event-Driven Architecture**: InspectorClient dispatches `oauthAuthorizationRequired` events. Callers must register event listeners to handle these events. - -**UX Layer Options**: - -1. **Console Output**: Register event listener to print URL, wait for user to paste callback URL or authorization code -2. **Browser Open**: Register event listener to open URL in default browser (if available) -3. **Custom Navigation**: Register event listener to handle redirect in any custom way - -**Example Flow**: - -``` -1. InspectorClient detects 401 error -2. Initiates OAuth flow -3. Dispatches 'oauthAuthorizationRequired' event -4. If no listener registered, prints: "Please navigate to: https://auth.example.com/authorize?..." -5. UX layer listens for event and handles navigation (print, open browser, etc.) -6. Waits for user to provide authorization code or callback URL -7. User calls client.completeOAuthFlow(code) -8. Dispatches 'oauthComplete' event -9. Retries original request -``` - -## Error Handling - -### OAuth Flow Errors - -- **Discovery Errors**: Log and continue (fallback to server URL) -- **Registration Errors**: Log and throw (user must provide static client) -- **Authorization Errors**: Dispatch `oauthError` event, throw error -- **Token Exchange Errors**: Dispatch `oauthError` event, throw error - -### 401 Error Handling - -- **Transport / authProvider**: The SDK transport handles 401 when `authProvider` is used (token injection, auth, retry). InspectorClient does not detect 401 or retry connect/requests. -- **Caller flow**: Authenticate first (`authenticate()` or `authenticateGuided()`), complete OAuth, then `connect()`. If `connect()` is called without tokens, the transport may throw; callers retry `connect()` after OAuth. -- **Event-Based**: Dispatch events for UI to handle OAuth flow (`oauthAuthorizationRequired`, etc.) - -## Migration Notes - -### authProvider Migration (2025) - -InspectorClient now uses the SDK’s **`authProvider`** (`OAuthClientProvider`) for OAuth on HTTP transports (SSE, streamable-http) instead of a `getOAuthToken` callback and custom 401 handling. - -**Summary of changes**: - -- **Transport**: `createTransport` accepts `authProvider` (optional). For SSE and streamable-http with OAuth, we pass the provider; the SDK injects tokens and handles 401. `getOAuthToken` and OAuth-specific fetch wrapping have been removed. -- **InspectorClient**: All transport creation happens in `connect()` (single place for create, wrap, attach); for HTTP+OAuth the provider is created async there. We pass `authProvider` when creating the transport. On `disconnect()`, we null out the transport so the next `connect()` creates a fresh one. Removed: `is401Error`, `handleRequestWithOAuth`, connect-time 401 catch, and `pendingOAuthRequest`. -- **Caller flow**: **Authenticate first, then connect.** Call `authenticate()` or `authenticateGuided()`, have the user complete OAuth, call `completeOAuthFlow(code)`, then `connect()`. We no longer detect 401 on `connect()` or retry internally; the transport handles 401 when `authProvider` is used. -- **Guided mode**: Unchanged. Use `authenticateGuided()` → `completeOAuthFlow()` → `connect()`. The same provider (or shared storage) is used as `authProvider` when connecting after guided auth. -- **Custom headers**: Config `headers` / `requestInit` / `eventSourceInit` continue to be passed at transport creation and are merged with `authProvider` by the SDK. - -See **"Token Injection and authProvider"** above for details. - -### Web Client Migration (Future Consideration) - -**Current State**: Web client OAuth code remains unchanged and in place. - -**Future Migration Options** (not implemented now, but design should support): - -1. **Option A: Web Client Uses Shared Auth Code Directly** - - Web client imports from `core/auth/` - - Uses `BrowserOAuthClientProvider` from shared - - Uses Zustand store with sessionStorage adapter - - Minimal changes to web client code - -2. **Option B: Web Client Relies on InspectorClient for OAuth** - - Web client creates `InspectorClient` instance - - Uses InspectorClient's OAuth methods and events - - InspectorClient handles all OAuth logic - - Web client UI listens to InspectorClient events - -3. **Option C: Hybrid Approach** - - Some components use shared auth code directly (e.g., utilities, state machine) - - Other components use InspectorClient (e.g., OAuth flow initiation) - - Flexible migration path - -**Design Considerations**: - -- Shared auth code should be usable independently (not require InspectorClient) -- InspectorClient should be usable independently (not require web client) -- React hooks in `core/react/auth/hooks.ts` can be shared (pure logic, no rendering) -- React UI components cannot be shared (TUI uses Ink, web uses DOM) - each client implements its own - -### Breaking Changes - -- **None Expected**: All changes are additive (new shared code, new InspectorClient features) -- **Web Client**: Remains completely unchanged -- **API Compatibility**: InspectorClient API is additive only - -## Future Enhancements - -1. **Token Refresh**: Implemented via the SDK's `authProvider` when `refresh_token` is available; the provider persists and uses refresh tokens for automatic refresh after 401. No additional work required for standard flows. -2. **Encrypted Storage**: Encrypt sensitive OAuth data in Zustand store -3. **Multiple OAuth Providers**: Support multiple OAuth configurations per InspectorClient -4. **Web Client Migration**: Consider migrating web client to use shared auth code or InspectorClient - -## References - -- Web client OAuth implementation (unchanged): `client/src/lib/auth.ts`, `client/src/lib/oauth-state-machine.ts`, `client/src/utils/oauthUtils.ts` -- [MCP SDK OAuth APIs](https://github.com/modelcontextprotocol/typescript-sdk) - SDK OAuth client and server APIs -- [OAuth 2.1 Specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) - OAuth 2.1 protocol specification -- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - OAuth 2.0 Authorization Server Metadata -- [Zustand Documentation](https://github.com/pmndrs/zustand) - Zustand state management library -- [Zustand Persist Middleware](https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md) - Zustand persistence middleware -- [SEP-991: Client ID Metadata Documents](https://modelcontextprotocol.io/specification/security/oauth/#client-id-metadata-documents) - CIMD specification diff --git a/docs/protocol-and-state-managers-architecture.md b/docs/protocol-and-state-managers-architecture.md index b7f992609..e233f94fb 100644 --- a/docs/protocol-and-state-managers-architecture.md +++ b/docs/protocol-and-state-managers-architecture.md @@ -29,7 +29,7 @@ This document describes how the Inspector protocol and state managers work: **In - **OAuth and session:** Connection and auth; dispatches `saveSession` with sessionId (persistence is in FetchRequestLogState). - **Log events (per-entry only):** Dispatches `message`, `fetchRequest`, `stderrLog` with payload. Does not maintain log lists or emit list-change events; log managers do that. -**Still inside InspectorClient (not extracted to external managers):** roots (and `getRoots()`), pendingSamples, pendingElicitations, receiverTaskRecords, OAuth state machine, sessionId. These may be refactored into internal sub-managers later; see [inspector-client-sub-managers.md](inspector-client-sub-managers.md). +**Still inside InspectorClient (not extracted to external managers):** roots (and `getRoots()`), pendingSamples, pendingElicitations, receiverTaskRecords, OAuth state machine, sessionId. These may be refactored into internal sub-managers later. --- diff --git a/docs/shared-code-architecture.md b/docs/shared-code-architecture.md index 65a2b6439..0c324bf80 100644 --- a/docs/shared-code-architecture.md +++ b/docs/shared-code-architecture.md @@ -47,9 +47,11 @@ The architecture addresses these issues by providing a single source of truth fo ``` inspector/ -ā”œā”€ā”€ cli/ # CLI workspace (uses core) -ā”œā”€ā”€ tui/ # TUI workspace (uses core) -ā”œā”€ā”€ web/ # Web client workspace (uses core) +ā”œā”€ā”€ clients/ +│ ā”œā”€ā”€ cli/ # CLI workspace (uses core) +│ ā”œā”€ā”€ tui/ # TUI workspace (uses core) +│ ā”œā”€ā”€ web/ # Web client workspace (uses core) +│ └── launcher/ # Global binary wrapper ā”œā”€ā”€ core/ # Shared workspace package (InspectorClient, state managers, react, auth) │ ā”œā”€ā”€ mcp/ # InspectorClient (protocol) + state managers │ │ └── state/ # Optional state managers (tools, resources, logs, tasks) @@ -227,7 +229,7 @@ The web client uses InspectorClient for all MCP operations: ### Feature coverage -InspectorClient supports OAuth (static client, CIMD, DCR, guided auth), completions (`getCompletions`), elicitation, sampling, roots, progress notifications, and custom headers via `MCPServerConfig`. For which features are implemented in the TUI vs. web client, see [tui-web-client-feature-gaps.md](tui-web-client-feature-gaps.md). +InspectorClient supports OAuth (static client, CIMD, DCR, guided auth), completions (`getCompletions`), elicitation, sampling, roots, progress notifications, and custom headers via `MCPServerConfig`. For which features are implemented in the TUI vs. web client, see [mcp-feature-tracker.md](mcp-feature-tracker.md). ## Web App Integration @@ -286,4 +288,4 @@ The architecture provides: - **Type safety** through shared types - **Event-driven updates** via EventTarget (cross-platform compatible) -**As-built:** CLI, TUI, and web client use InspectorClient from core. TUI and web use state managers and the same React hooks; CLI calls InspectorClient methods directly. For TUI vs. web feature coverage, see [tui-web-client-feature-gaps.md](tui-web-client-feature-gaps.md). +**As-built:** CLI, TUI, and web client use InspectorClient from core. TUI and web use state managers and the same React hooks; CLI calls InspectorClient methods directly. For TUI vs. web feature coverage, see [mcp-feature-tracker.md](mcp-feature-tracker.md). diff --git a/docs/tui-oauth-implementation-plan.md b/docs/tui-oauth-implementation-plan.md deleted file mode 100644 index 0914efb08..000000000 --- a/docs/tui-oauth-implementation-plan.md +++ /dev/null @@ -1,184 +0,0 @@ -# TUI OAuth Implementation Plan - -## Overview - -This document describes OAuth 2.1 support in the TUI for MCP servers that require OAuth (e.g. GitHub Copilot MCP). The implementation supports **DCR**, **CIMD**, and **static client** (clientId/clientSecret in config). - -**Goals:** - -- Enable TUI to connect to OAuth-protected MCP servers (SSE, streamable-http). -- Use a **localhost callback server** to receive the OAuth redirect (authorization code). -- Share callback-server logic between TUI and CLI where possible. -- Support both **Quick Auth** (automatic flow) and **Guided Auth** (step-by-step) with a **single redirect URL**. - -**Scope:** - -- **Quick Auth**: Automatic flow via `authenticate()`. Single redirect URI `http://localhost:/oauth/callback`. -- **Guided Auth**: Step-by-step flow via `beginGuidedAuth()`, `proceedOAuthStep()`, `runGuidedAuth()`. Same redirect URI; mode embedded in OAuth `state` parameter. - ---- - -## Implementation Status - -### Completed - -| Component | Status | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **Callback server** | Done. `core/auth/oauth-callback-server.ts`, single path `/oauth/callback`, serves both normal and guided flows. | -| **TUI integration** | Done. Auth available for all HTTP servers (SSE, streamable-http). Auth tab with Guided / Quick / Clear. | -| **Quick Auth** | Done. `authenticate()`, callback server, `openUrl`, `completeOAuthFlow`. | -| **Guided Auth** | Done. `beginGuidedAuth`, `proceedOAuthStep`, `runGuidedAuth`. Step progress UI, Space to advance, Enter to run to completion. | -| **Single redirect URL** | Done. Mode embedded in `state` (`normal:...` or `guided:...`). One `redirect_uri` registered with OAuth server. | -| **401 handling** | Done. On connect failure, if 401 seen in fetch history, show "401 Unauthorized. Press A to authenticate." | -| **DCR / CIMD** | Done. InspectorClient supports Dynamic Client Registration and CIMD. | - -### Static Client Auth - -**InspectorClient** supports static client configuration (`clientId`, `clientSecret` in oauth config). The **TUI does not yet support static client configuration**—there is no UI or config wiring for `clientId`/`clientSecret`. Adding this is pending work. - -### Pending Work - -1. **Callback state validation (optional)** - - Store the state we sent when building the auth URL. On callback, parse `state` via `parseOAuthState()` and verify the random part matches. - - Hardening step; current flow works without it since only one active flow runs at a time. - -2. **OAuth config in TUI** - - Add support for oauth options: `scope`, `storagePath`, `clientId`, `clientSecret`, `clientMetadataUrl`. - - Store in auth store for the server (or elsewhere)—**not** in server config. - - Wire through to InspectorClient when creating auth provider. - -3. **Redirect URI / port change** - - If the callback server restarts with a different port (e.g. between auth attempts), the OAuth server may have the old redirect_uri registered, causing "Unregistered redirect_uri". Workaround: clear OAuth state before retrying. Potential improvement: reuse port or document the limitation. - -4. **CLI OAuth** - - Wire the same callback server into the CLI for HTTP servers. Flow: start callback server, run `authenticate()`, open URL, receive callback, `completeOAuthFlow`, then connect. - ---- - -## Assumptions - -- **DCR, CIMD, or static client**: Auth options (clientId, clientSecret, clientMetadataUrl, etc.) live in auth store or similar—not in server config. -- **Discovery runs in Node**: TUI and CLI run in Node. OAuth metadata discovery uses `fetch` in Node—**no CORS** issues. -- **Single redirect URI**: Both normal and guided flows use `http://localhost:/oauth/callback`. Mode is embedded in the `state` parameter. - ---- - -## Single Redirect URL (Mode in State) - -We use **one redirect URL** for both normal and guided flows. The **mode** is embedded in the OAuth `state` parameter, which the authorization server echoes back unchanged. - -### State Format - -``` -{mode}:{random} -``` - -- `normal:a1b2c3...` (64 hex chars after colon) -- `guided:a1b2c3...` - -The random part is 32 bytes (64 hex chars) for CSRF protection. Legacy state (plain 64-char hex) is treated as `"normal"`. - -### Implementation - -- `generateOAuthStateWithMode(mode)` and `parseOAuthState(state)` in `core/auth/utils.ts` -- `BaseOAuthClientProvider.state()` uses mode-embedded state -- `redirect_uris` returns a single URL for both modes -- Callback server serves `/oauth/callback` only - ---- - -## Callback Server - -### Location - -- `core/auth/oauth-callback-server.ts` -- Exported from `@modelcontextprotocol/inspector-core/auth` - -### API - -```ts -type OAuthCallbackHandler = (params: { code: string; state?: string }) => Promise; -type OAuthErrorHandler = (params: { error: string; error_description?: string }) => void; - -start(options: { - port?: number; - onCallback?: OAuthCallbackHandler; - onError?: OAuthErrorHandler; -}): Promise<{ port: number; redirectUrl: string }>; - -stop(): Promise; -``` - -### Behavior - -1. Listens on configurable port (default `0` → OS-assigned). -2. Serves `GET /oauth/callback` only (both normal and guided). -3. On success: invokes `onCallback` with `{ code, state }`, responds with "OAuth complete. You can close this window.", then stops. -4. On error: invokes `onError`, responds with error HTML. -5. Caller must **not** `await callbackServer.stop()` inside `onCallback`; the server stops itself after sending the response (avoids deadlock). - ---- - -## TUI Flow - -### Config - -- Auth is available for all HTTP servers (SSE, streamable-http). -- **Auth config is not stored in server config.** OAuth options (scope, storagePath, clientId, clientSecret, clientMetadataUrl) will live in the auth store for the server or elsewhere—not in the MCP server config. -- `redirectUrl` is set from the callback server when the user starts auth. - -### Auth Tab - -- **Guided Auth**: Step-by-step. Space to advance one step, Enter to run to completion. -- **Quick Auth**: Automatic flow. -- **Clear OAuth State**: Clears tokens and state. -- Accelerators: G (Guided), Q (Quick), S (Clear) switch to Auth tab and select the corresponding action. - -### End-to-End Flow (Quick Auth) - -1. User selects HTTP server, presses Q or selects Quick Auth and Enter. -2. TUI starts callback server, sets `redirectUrl` on provider. -3. Calls `authenticate()`. -4. On `oauthAuthorizationRequired`, opens auth URL in browser. -5. User signs in; IdP redirects to `http://localhost:/oauth/callback?code=...&state=...`. -6. Callback server receives request, calls `completeOAuthFlow(code)`, responds with success page. -7. TUI shows "OAuth complete. Press C to connect." - -### End-to-End Flow (Guided Auth) - -1. User selects HTTP server, presses G or selects Guided Auth. -2. TUI starts callback server, sets `redirectUrl`, calls `beginGuidedAuth()`. -3. User advances with Space (or runs to completion with Enter). -4. At authorization step, browser opens with auth URL (state includes `guided:...`). -5. User signs in; IdP redirects to same `/oauth/callback` with code and state. -6. Callback server receives, calls `completeOAuthFlow(code)`, responds with success page. -7. TUI shows completion. - ---- - -## Config Shape - -**MCP server config:** - -```json -{ - "mcpServers": { - "hosted-everything": { - "type": "streamable-http", - "url": "https://example-server.modelcontextprotocol.io/mcp" - } - } -} -``` - -- Auth is available for all HTTP servers. Server config stays clean—**no oauth block**. -- Auth options (scope, storagePath, clientId, clientSecret, clientMetadataUrl) are **not** stored in server config. They will live in the auth store for the server or elsewhere. TUI does not yet support configuring these; defaults only. - ---- - -## References - -- [OAuth Support in InspectorClient](./oauth-inspectorclient-design.md) -- [TUI and Web Client Feature Gaps](./tui-web-client-feature-gaps.md) -- `core/auth/`: providers, state-machine, utils, storage-node, oauth-callback-server -- `core/mcp/inspectorClient.ts`: `authenticate`, `beginGuidedAuth`, `runGuidedAuth`, `proceedOAuthStep`, `completeOAuthFlow`, `authProvider` diff --git a/docs/tui-web-client-feature-gaps.md b/docs/tui-web-client-feature-gaps.md deleted file mode 100644 index 9747657b6..000000000 --- a/docs/tui-web-client-feature-gaps.md +++ /dev/null @@ -1,756 +0,0 @@ -# TUI and Web Client Feature Gap Analysis - -## Overview - -This document details the feature gaps between the TUI (Terminal User Interface) and the web client. The goal is to identify all missing features in the TUI and create a plan to close these gaps by extending `InspectorClient` and implementing the features in the TUI. - -**Code locations:** On the main/v1 tree the web app lives in `client/` (and sometimes `server/`). On this branch the web app lives in `web/`. Code references in this document use `web/` for the current app unless explicitly referring to the v1/main tree. - -## Feature Comparison - -**InspectorClient** is the shared client library that provides the core MCP functionality. Both the TUI and web client use `InspectorClient` under the hood. The gaps documented here are primarily **UI-level gaps** — features that `InspectorClient` supports but are not yet exposed in the TUI interface. - -For the current feature matrix (InspectorClient, Web v1, Web v1.5, TUI), see [MCP Feature Implementation Across Projects](mcp-feature-tracker.md). - -## Detailed Feature Gaps - -### 1. Resource Subscriptions - -**Web Client Support:** - -- Subscribes to resources via `resources/subscribe` -- Unsubscribes via `resources/unsubscribe` -- Tracks subscribed resources in state -- UI shows subscription status and subscribe/unsubscribe buttons -- Handles `notifications/resources/updated` notifications for subscribed resources - -**TUI Status:** - -- āŒ No support for resource subscriptions -- āŒ No subscription state management -- āŒ No UI for subscribe/unsubscribe actions - -**InspectorClient Status:** - -- āœ… `subscribeToResource(uri)` method - **COMPLETED** -- āœ… `unsubscribeFromResource(uri)` method - **COMPLETED** -- āœ… Subscription state tracking - **COMPLETED** (`getSubscribedResources()`, `isSubscribedToResource()`) -- āœ… Handler for `notifications/resources/updated` - **COMPLETED** -- āœ… `resourceSubscriptionsChange` event - **COMPLETED** -- āœ… `resourceUpdated` event - **COMPLETED** -- āœ… Cache clearing on resource updates - **COMPLETED** (clears both regular resources and resource templates with matching expandedUri) - -**TUI Status:** - -- āŒ No UI for resource subscriptions -- āŒ No subscription state management in UI -- āŒ No UI for subscribe/unsubscribe actions -- āŒ No handling of resource update notifications in UI - -**Implementation Requirements:** - -- āœ… Add `subscribeToResource(uri)` and `unsubscribeFromResource(uri)` methods to `InspectorClient` - **COMPLETED** -- āœ… Add subscription state tracking in `InspectorClient` - **COMPLETED** -- āŒ Add UI in TUI `ResourcesTab` for subscribe/unsubscribe actions -- āœ… Handle resource update notifications for subscribed resources - **COMPLETED** (in InspectorClient) - -**Code References:** - -- Web client: `web/src/App.tsx` (lines 781-809) -- Web client: `web/src/components/ResourcesTab.tsx` (lines 207-221) - -### 2. OAuth 2.1 Authentication - -**InspectorClient Support:** - -- OAuth 2.1 support in shared package (`core/auth/`), integrated via `authProvider` on HTTP transports (SSE, streamable-http) -- **Static/Preregistered Clients**: āœ… Supported -- **DCR (Dynamic Client Registration)**: āœ… Supported -- **CIMD (Client ID Metadata Documents)**: āœ… Supported via `clientMetadataUrl` in OAuth config -- Authorization code flow with PKCE, token exchange, token refresh (via SDK `authProvider` when `refresh_token` available) -- Guided mode (`authenticateGuided()`, `proceedOAuthStep()`, `getOAuthStep()`) and normal mode (`authenticate()`, `completeOAuthFlow()`) -- Configurable storage path (`oauth.storagePath`), default `~/.mcp-inspector/oauth/state.json` -- Events: `oauthAuthorizationRequired`, `oauthComplete`, `oauthError`, `oauthStepChange` - -**Web Client Support:** - -- Full browser-based OAuth 2.1 flow (uses its own OAuth code in `web/src/lib/`, unchanged): - - **Static/Preregistered Clients**: āœ… Supported - User provides client ID and secret via UI - - **DCR (Dynamic Client Registration)**: āœ… Supported - Falls back to DCR if no static client available - - **CIMD (Client ID Metadata Documents)**: āŒ Not supported - Web client does not set `clientMetadataUrl` - - Authorization code flow with PKCE, token exchange, token refresh -- OAuth state management via `InspectorOAuthClientProvider` -- Session storage for OAuth tokens, OAuth callback handling, automatic token injection into request headers - -**TUI Status:** - -- āœ… OAuth 2.1 support including static client, CIMD, DCR, and guided auth -- āœ… OAuth token management via Auth tab -- āœ… Browser-based OAuth flow with localhost callback server -- āœ… CLI options: `--client-id`, `--client-secret`, `--client-metadata-url`, `--callback-url` - -**Code References:** - -- InspectorClient OAuth: `core/mcp/inspectorClient.ts` (OAuth options, `authenticate`, `authenticateGuided`, `completeOAuthFlow`, events), `core/auth/` -- Web client: `web/src/lib/hooks/useConnection.ts`, `web/src/lib/auth.ts`, `web/src/lib/oauth-state-machine.ts` -- Design: [OAuth Support in InspectorClient](./oauth-inspectorclient-design.md) - -**Note:** OAuth in TUI requires a browser-based flow with a localhost callback server, which is feasible but different from the web client's approach. - -### 3. Sampling Requests - -**InspectorClient Support:** - -- āœ… Declares `sampling: {}` capability in client initialization (via `sample` option, default: `true`) -- āœ… Sets up request handler for `sampling/createMessage` requests automatically -- āœ… Tracks pending sampling requests via `getPendingSamples()` -- āœ… Provides `SamplingCreateMessage` class with `respond()` and `reject()` methods -- āœ… Dispatches `newPendingSample` and `pendingSamplesChange` events -- āœ… Methods: `getPendingSamples()`, `removePendingSample(id)` - -**Web Client Support:** - -- UI tab (`SamplingTab`) displays pending sampling requests -- `SamplingRequest` component shows request details and approval UI -- Handles approve/reject actions via `SamplingCreateMessage.respond()`/`reject()` -- Listens to `newPendingSample` events to update UI - -**TUI Status:** - -- āŒ No UI for sampling requests -- āŒ No sampling request display or handling UI - -**Implementation Requirements:** - -- Add UI in TUI for displaying pending sampling requests -- Add UI for approve/reject actions (call `respond()` or `reject()` on `SamplingCreateMessage`) -- Listen to `newPendingSample` and `pendingSamplesChange` events -- Add sampling tab or integrate into existing tabs - -**Code References:** - -- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 85-87, 225-226, 401-417, 573-600) -- Web client: `web/src/components/SamplingTab.tsx` -- Web client: `web/src/components/SamplingRequest.tsx` -- Web client: `web/src/App.tsx` (lines 328-333, 637-652) - -### 4. Elicitation Requests - -**InspectorClient Support:** - -- āœ… Declares `elicitation: {}` capability in client initialization (via `elicit` option, default: `true`) -- āœ… Sets up request handler for `elicitation/create` requests automatically -- āœ… Tracks pending elicitation requests via `getPendingElicitations()` -- āœ… Provides `ElicitationCreateMessage` class with `respond()` and `remove()` methods -- āœ… Dispatches `newPendingElicitation` and `pendingElicitationsChange` events -- āœ… Methods: `getPendingElicitations()`, `removePendingElicitation(id)` -- āœ… Supports both form-based (user-input) and URL-based elicitation modes - -#### 4a. Form-Based Elicitation (User-Input) - -**InspectorClient Support:** - -- āœ… Handles `ElicitRequest` with `requestedSchema` (form-based mode) -- āœ… Extracts `taskId` from `related-task` metadata when present -- āœ… Test fixtures: `createCollectElicitationTool()` for testing form-based elicitation - -**Web Client Support:** - -- āœ… UI tab (`ElicitationTab`) displays pending form-based elicitation requests -- āœ… `ElicitationRequest` component: - - Shows request message and schema - - Generates dynamic form from JSON schema - - Validates form data against schema - - Handles accept/decline/cancel actions via `ElicitationCreateMessage.respond()` -- āœ… Listens to `newPendingElicitation` events to update UI - -**TUI Status:** - -- āŒ No UI for form-based elicitation requests -- āŒ No form generation from JSON schema -- āŒ No UI for accept/decline/cancel actions - -**Implementation Requirements:** - -- Add UI in TUI for displaying pending form-based elicitation requests -- Add form generation from JSON schema (similar to tool parameter forms) -- Add UI for accept/decline/cancel actions (call `respond()` on `ElicitationCreateMessage`) -- Listen to `newPendingElicitation` and `pendingElicitationsChange` events -- Add elicitation tab or integrate into existing tabs - -#### 4b. URL-Based Elicitation - -**InspectorClient Support:** - -- āœ… Handles `ElicitRequest` with `mode: "url"` and `url` parameter -- āœ… Extracts `taskId` from `related-task` metadata when present -- āœ… Test fixtures: `createCollectUrlElicitationTool()` for testing URL-based elicitation - -**Web Client Support:** - -- āŒ No UI for URL-based elicitation requests -- āŒ No handling for URL-based elicitation mode - -**TUI Status:** - -- āŒ No UI for URL-based elicitation requests -- āŒ No handling for URL-based elicitation mode - -**Implementation Requirements:** - -- Add UI in TUI for displaying pending URL-based elicitation requests -- Add UI to display URL and message to user -- Add UI for accept/decline/cancel actions (call `respond()` on `ElicitationCreateMessage`) -- Optionally: Open URL in browser or provide copy-to-clipboard functionality -- Listen to `newPendingElicitation` and `pendingElicitationsChange` events -- Add elicitation tab or integrate into existing tabs - -**Code References:** - -- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 90-92, 227-228, 420-433, 606-639) -- `ElicitationCreateMessage`: `core/mcp/elicitationCreateMessage.ts` -- Test fixtures: `core/test/test-server-fixtures.ts` (`createCollectElicitationTool`, `createCollectUrlElicitationTool`) -- Web client: `web/src/components/ElicitationTab.tsx` -- Web client: `web/src/components/ElicitationRequest.tsx` (form-based only) -- Web client: `web/src/App.tsx` (lines 334-356, 653-669) -- Web client: `web/src/utils/schemaUtils.ts` (schema resolution for form-based elicitation) - -### 5. Tasks (Long-Running Operations) - -**Status:** - -- āœ… **COMPLETED** - Fully implemented in InspectorClient -- āœ… Implemented in web client (as of recent release) -- āŒ Not yet implemented in TUI - -**Overview:** -Tasks (SEP-1686) were introduced in MCP version 2025-11-25 to support long-running operations through a "call-now, fetch-later" pattern. Tasks enable servers to return a taskId immediately and allow clients to poll for status and retrieve results later, avoiding connection timeouts. - -**InspectorClient Support:** - -- āœ… `callToolStream()` method - Calls tools with task support, returns streaming updates -- āœ… `getTask(taskId)` method - Retrieves task status by taskId -- āœ… `getTaskResult(taskId)` method - Retrieves task result once completed -- āœ… `cancelTask(taskId)` method - Cancels a running task -- āœ… `listTasks(cursor?)` method - Lists all active tasks with pagination support -- āœ… `getTrackedRequestorTasks()` method - Returns array of currently tracked requestor tasks -- āœ… Task state tracking - Maintains cache of active tasks with automatic updates -- āœ… Task lifecycle events - Dispatches `taskCreated`, `taskStatusChange`, `taskCompleted`, `taskFailed`, `taskCancelled`, `tasksChange` events -- āœ… Elicitation integration - Links elicitation requests to tasks via `related-task` metadata -- āœ… Sampling integration - Links sampling requests to tasks via `related-task` metadata -- āœ… Progress notifications - Links progress notifications to tasks via `related-task` metadata -- āœ… Capability detection - `getTaskCapabilities()` checks server task support -- āœ… `callTool()` validation - Throws error if attempting to call tool with `taskSupport: "required"` using `callTool()` -- āœ… Task cleanup - Clears task cache on disconnect - -**Web Client Support:** - -- UI displays active tasks with status indicators -- Task status updates in real-time via event listeners -- Task cancellation UI (cancel button for running tasks) -- Task result display when tasks complete -- Integration with tool calls - shows task creation from `callToolStream()` -- Links tasks to elicitation/sampling requests when task is `input_required` - -**TUI Status:** - -- āŒ No UI for displaying active tasks -- āŒ No task status display or monitoring -- āŒ No task cancellation UI -- āŒ No task result display -- āŒ No integration with tool calls (tasks created via `callToolStream()` are not visible) -- āŒ No indication when tool requires task support (`taskSupport: "required"`) -- āŒ No linking of tasks to elicitation/sampling requests in UI -- āŒ No task lifecycle event handling in UI - -**Implementation Requirements:** - -- āœ… InspectorClient task support - **COMPLETED** (see [Task Support Design](./task-support-design.md)) -- āŒ Add TUI UI for task management: - - Display list of active tasks with status (`working`, `input_required`, `completed`, `failed`, `cancelled`) - - Show task details (taskId, status, statusMessage, createdAt, lastUpdatedAt) - - Display task results when completed - - Cancel button for running tasks (call `cancelTask()`) - - Real-time status updates via `taskStatusChange` event listener - - Task lifecycle event handling (`taskCreated`, `taskCompleted`, `taskFailed`, `taskCancelled`) -- āŒ Integrate tasks with tool calls: - - Use `callToolStream()` for tools with `taskSupport: "required"` (instead of `callTool()`) - - Show task creation when tool call creates a task - - Link tool call results to tasks -- āŒ Integrate tasks with elicitation/sampling: - - Display which task is waiting for input when elicitation/sampling request has `taskId` - - Show task status as `input_required` while waiting for user response - - Link elicitation/sampling UI to associated task -- āŒ Add task capability detection: - - Check `getTaskCapabilities()` to determine if server supports tasks - - Only show task UI if server supports tasks -- āŒ Handle task-related errors: - - Show error when attempting to call `taskSupport: "required"` tool with `callTool()` - - Display task failure messages from `taskFailed` events - -### 6. Completions - -**InspectorClient Support:** - -- āœ… `getCompletions()` method sends `completion/complete` requests -- āœ… Supports resource template completions: `{ type: "ref/resource", uri: string }` -- āœ… Supports prompt argument completions: `{ type: "ref/prompt", name: string }` -- āœ… Handles `MethodNotFound` errors gracefully (returns empty array if server doesn't support completions) -- āœ… Completion requests include: - - `ref`: Resource template URI or prompt name - - `argument`: Field name and current (partial) value - - `context`: Optional context with other argument values -- āœ… Returns `{ values: string[] }` with completion suggestions - -**Web Client Support:** - -- Detects completion capability via `serverCapabilities.completions` -- `handleCompletion()` function calls `InspectorClient.getCompletions()` -- Used in resource template forms for autocomplete -- Used in prompt forms with parameters for autocomplete -- `useCompletionState` hook manages completion state and debouncing - -**TUI Status:** - -- āœ… Prompt fetching with parameters - **COMPLETED** (modal form for collecting prompt arguments) -- āŒ No completion support for resource template forms -- āŒ No completion support for prompt parameter forms -- āŒ No completion capability detection in UI -- āŒ No completion request handling in UI - -**Implementation Requirements:** - -- Add completion capability detection in TUI (via `InspectorClient.getCapabilities()?.completions`) -- Integrate `InspectorClient.getCompletions()` into TUI forms: - - **Resource template forms** (`ResourceTestModal`) - autocomplete for template variable values - - **Prompt parameter forms** (`PromptTestModal`) - autocomplete for prompt argument values -- Add completion state management (debouncing, loading states) -- Trigger completions on input change with debouncing - -**Code References:** - -- `InspectorClient`: `core/mcp/inspectorClient.ts` (lines 902-966) - `getCompletions()` method -- Web client: `web/src/lib/hooks/useConnection.ts` (lines 309, 384-386) -- Web client: `web/src/lib/hooks/useCompletionState.ts` -- Web client: `web/src/components/ResourcesTab.tsx` (lines 88-101) -- TUI: `tui/src/components/PromptTestModal.tsx` - Prompt form (needs completion integration) -- TUI: `tui/src/components/ResourceTestModal.tsx` - Resource template form (needs completion integration) - -### 6. Progress Tracking - -**Use Case:** - -Long-running operations (tool calls, resource reads, prompt invocations, etc.) can send progress notifications (`notifications/progress`) to keep clients informed of execution status. This is useful for: - -- Showing progress bars or status updates -- Resetting request timeouts on progress notifications -- Providing user feedback during long operations - -**Request timeouts and `resetTimeoutOnProgress`:** - -The MCP SDK applies a **per-request timeout** to all client requests (`listTools`, `callTool`, `getPrompt`, etc.). If no `timeout` is passed in `RequestOptions`, the SDK uses `DEFAULT_REQUEST_TIMEOUT_MSEC` (60 seconds). When the timeout is exceeded, the SDK raises an `McpError` with code `RequestTimeout` and the request fails—even if the server is still working and sending progress. - -**`resetTimeoutOnProgress`** is an SDK `RequestOptions` flag. When `true`, each `notifications/progress` received for that request **resets** the per-request timeout. That allows long-running operations that send periodic progress to run beyond 60 seconds without failing. (An optional `maxTotalTimeout` caps total wait time regardless of progress.) - -The SDK runs timeout reset **only** when a per-request `onprogress` callback exists; it also injects `progressToken: messageId` for routing. `InspectorClient` passes per-request `onprogress` when progress is enabled (so timeout reset **takes effect**) and collects the caller's `progressToken` from metadata. We **do not** expose that token to the server—the SDK overwrites it with `messageId`—we inject it only into dispatched `progressNotification` events so listeners can correlate progress with the request that triggered it. We pass `resetTimeoutOnProgress` (default: `true`) and optional `timeout` through; both are honored. Set `resetTimeoutOnProgress: false` for strict timeout caps or fail-fast behavior. - -**Web Client Support:** - -- **Progress Token**: Generates and includes `progressToken` in request metadata: - ```typescript - const mergedMetadata = { - ...metadata, - progressToken: progressTokenRef.current++, - ...toolMetadata, - }; - ``` -- **Progress Callback**: Sets up `onprogress` callback in `useConnection`: - ```typescript - if (mcpRequestOptions.resetTimeoutOnProgress) { - mcpRequestOptions.onprogress = (params: Progress) => { - if (onNotification) { - onNotification({ - method: "notifications/progress", - params, - }); - } - }; - } - ``` -- **Progress Display**: Progress notifications are displayed in the "Server Notifications" window -- **Timeout Reset**: `resetTimeoutOnProgress` option resets request timeout when progress notifications are received - -**InspectorClient Status:** - -- āœ… Progress - Per-request `onprogress` when `progress` enabled; dispatches `progressNotification` events (no global progress handler) -- āœ… Progress token - Accepts `progressToken` in metadata; we inject it into dispatched events only (not sent to server), so listeners can correlate -- āœ… Event-based - Clients listen for `progressNotification` events -- āœ… Timeout reset - `resetTimeoutOnProgress` (default: `true`), optional `timeout`; both honored via per-request `onprogress` - -**TUI Status:** - -- āŒ No progress tracking support -- āŒ No progress notification display -- āŒ No progress token management - -**Implementation Requirements:** - -- āœ… **Completed in InspectorClient:** - - Per-request `onprogress` when `progress: true`; dispatch `progressNotification` events from callback (no global progress handler) - - Caller's `progressToken` from metadata injected into events only (not sent to server); full params include `progress`, `total`, `message` - - `progressToken` in metadata supported (e.g. `callTool`, `getPrompt`, `readResource`, list methods) - - `resetTimeoutOnProgress` (default: `true`) and optional `timeout` passed as `RequestOptions`; timeout reset honored -- āŒ **TUI UI Support Needed:** - - Show progress notifications during long-running operations - - Display progress status in results view - - Optional: Progress bars or percentage indicators - -**Code References:** - -- InspectorClient: `core/mcp/inspectorClient.ts` - `getRequestOptions(progressToken?)` builds per-request `onprogress`, injects token into dispatched events -- InspectorClient: `core/mcp/inspectorClient.ts` - `callTool`, `callToolStream`, `getPrompt`, `readResource`, list methods pass `metadata?.progressToken` into `getRequestOptions` -- Web client: `web/src/App.tsx` (lines 840-892) - Progress token generation and tool call -- Web client: `web/src/lib/hooks/useConnection.ts` (lines 214-226) - Progress callback setup -- SDK: `@modelcontextprotocol/sdk` `shared/protocol` - `DEFAULT_REQUEST_TIMEOUT_MSEC` (60_000), `RequestOptions` (`timeout`, `resetTimeoutOnProgress`, `maxTotalTimeout`), `Progress` type - -### 7. ListChanged Notifications - -**Use Case:** - -MCP servers can send `listChanged` notifications when the list of tools, resources, or prompts changes. This allows clients to automatically refresh their UI when the server's capabilities change, without requiring manual refresh actions. - -**Web Client Support:** - -- **Capability Declaration**: Declares `roots: { listChanged: true }` in client capabilities -- **Notification Handlers**: Sets up handlers for: - - `notifications/tools/list_changed` - - `notifications/resources/list_changed` - - `notifications/prompts/list_changed` -- **Auto-refresh**: When a `listChanged` notification is received, the web client automatically calls the corresponding `list*()` method to refresh the UI -- **Notification Processing**: All notifications are passed to `onNotification` callback, which stores them in state for display - -**InspectorClient and state managers:** - -- āœ… InspectorClient registers notification handlers for `notifications/tools/list_changed`, `notifications/resources/list_changed`, `notifications/prompts/list_changed` and dispatches **signal** events (e.g. `toolsListChanged`, `resourcesListChanged`, `promptsListChanged`) - **COMPLETED** -- āœ… **State managers** (in `core/mcp/state/`) subscribe to those signals, call list RPCs, hold lists/caches, and dispatch **state** events (`toolsChange`, `resourcesChange`, `resourceTemplatesChange`, `promptsChange`) - **COMPLETED**. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). - -**TUI Status:** - -- āœ… `listChanged` notifications flow: InspectorClient dispatches signals; state managers (if used) reload lists and dispatch `toolsChange` etc. - **COMPLETED** -- āœ… TUI automatically reflects changes when it subscribes to state manager events (or to InspectorClient signals and fetches list data) - **COMPLETED** -- āŒ No UI indication when lists are auto-refreshed (optional, but useful for debugging) - -**Note on TUI Support:** - -List and cache state live in **optional state managers**, not on InspectorClient. The flow: - -1. **Server Capability**: The MCP server must advertise `listChanged` capability (e.g., `tools: { listChanged: true }`, `resources: { listChanged: true }`, `prompts: { listChanged: true }`). - -2. **InspectorClient**: When connected, it registers notification handlers and dispatches signal events (`toolsListChanged`, etc.). It does not store lists or dispatch `toolsChange`/`resourcesChange`/etc. - -3. **State managers**: Optional managers (e.g. ManagedToolsState, PagedToolsState) subscribe to those signals, call `listTools()`/etc., hold the list, and dispatch `toolsChange` (and similar) with the current list. Apps and hooks subscribe to the managers. - -4. **TUI**: If the TUI uses state managers (or equivalent), it listens to their events to refresh the UI. - -**Important**: The client does NOT need to advertise `listChanged` capability - it only needs to check if the server supports it. The handlers are registered automatically based on server capabilities. - -**Implementation Requirements:** - -- āœ… InspectorClient: notification handlers in `connect()` for `listChanged`, dispatch signal events - **COMPLETED** -- āœ… State managers: subscribe to signals, call list RPCs, hold lists, dispatch `toolsChange`/etc. - **COMPLETED** -- āœ… TUI can use state managers (or subscribe to signals and fetch) for list updates - **COMPLETED** -- āŒ Add UI in TUI to handle and display these notifications (optional, but useful for debugging) - -**Code References:** - -- Web client: `web/src/lib/hooks/useConnection.ts` (or equivalent) - capability declaration and notification handling -- InspectorClient: `core/mcp/inspectorClient.ts` (listChanged handlers in `connect()`) - signal dispatch -- State managers: `core/mcp/state/`, [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md) - -### 8. Roots Support - -**Use Case:** - -Roots are file system paths (as `file://` URIs) that define which directories an MCP server can access. This is a security feature that allows servers to operate within a sandboxed set of directories. Clients can: - -- List the current roots configured on the server -- Set/update the roots (if the server supports it) -- Receive notifications when roots change - -**Web Client Support:** - -- **Capability Declaration**: Declares `roots: { listChanged: true }` in client capabilities -- **UI Component**: `RootsTab` component allows users to: - - View current roots - - Add new roots (with URI and optional name) - - Remove roots - - Save changes (calls `listRoots` with updated roots) -- **Roots Management**: - - `getRoots` callback passed to `useConnection` hook - - Roots are stored in component state - - When roots are changed, `handleRootsChange` is called to send updated roots to server -- **Notification Support**: Handles `notifications/roots/list_changed` notifications (via fallback handler) - -**InspectorClient Support:** - -- āœ… `getRoots()` method - Returns current roots -- āœ… `setRoots(roots)` method - Updates roots and sends notification to server if supported -- āœ… Handler for `roots/list` requests from server (returns current roots) -- āœ… Notification handler for `notifications/roots/list_changed` from server -- āœ… `roots: { listChanged: true }` capability declaration (when `roots` option is provided) -- āœ… `rootsChange` event dispatched when roots are updated -- āœ… Roots configured via `roots` option in `InspectorClientOptions` (even empty array enables capability) - -**TUI Status:** - -- āŒ No roots management UI -- āŒ No roots configuration support - -**Implementation Requirements:** - -- āœ… `getRoots()` and `setRoots()` methods - **COMPLETED** in `InspectorClient` -- āœ… Handler for `roots/list` requests - **COMPLETED** in `InspectorClient` -- āœ… Notification handler for `notifications/roots/list_changed` - **COMPLETED** in `InspectorClient` -- āœ… `roots: { listChanged: true }` capability declaration - **COMPLETED** in `InspectorClient` -- āŒ Add UI in TUI for managing roots (similar to web client's `RootsTab`) - -**Code References:** - -- `InspectorClient`: `core/mcp/inspectorClient.ts` - `getRoots()`, `setRoots()`, roots/list handler, and notification support -- Web client: `web/src/components/RootsTab.tsx` - Roots management UI -- Web client: `web/src/lib/hooks/useConnection.ts` (lines 422-424, 357) - Capability declaration and `getRoots` callback -- Web client: `web/src/App.tsx` (lines 1225-1229) - RootsTab usage - -### 9. Custom Headers - -**Use Case:** - -Custom headers are used to send additional HTTP headers when connecting to MCP servers over HTTP-based transports (SSE or streamable-http). Common use cases include: - -- **Authentication**: API keys, bearer tokens, or custom authentication schemes - - Example: `Authorization: Bearer ` - - Example: `X-API-Key: ` -- **Multi-tenancy**: Tenant or organization identifiers - - Example: `X-Tenant-ID: acme-inc` -- **Environment identification**: Staging vs production - - Example: `X-Environment: staging` -- **Custom server requirements**: Any headers required by the MCP server - -**InspectorClient Support:** - -- āœ… `MCPServerConfig` supports `headers: Record` for SSE and streamable-http transports -- āœ… Headers are passed to the SDK transport during creation -- āœ… Headers are included in all HTTP requests to the MCP server -- āœ… Works with both SSE and streamable-http transports -- āŒ Not supported for stdio transport (stdio doesn't use HTTP) - -**Web Client Support:** - -- **UI Component**: `CustomHeaders` component in the Sidebar's authentication section -- **Features**: - - Add/remove headers with name/value pairs - - Enable/disable individual headers (toggle switch) - - Mask header values by default (password field with show/hide toggle) - - Form mode: Individual header inputs - - JSON mode: Edit all headers as a JSON object - - Validation: Only enabled headers with both name and value are sent -- **Integration**: - - Headers are stored in component state - - Passed to `useConnection` hook - - Converted to `Record` format for transport - - OAuth tokens can be automatically injected into `Authorization` header if no custom `Authorization` header exists - - Custom header names are tracked and sent to the proxy server via `x-custom-auth-headers` header - -**TUI Status:** - -- āŒ No header configuration UI -- āŒ No way for users to specify custom headers in TUI server config -- āœ… `InspectorClient` supports headers if provided in config (but TUI doesn't expose this) - -**Implementation Requirements:** - -- Add header configuration UI in TUI server configuration -- Allow users to add/edit/remove headers similar to web client -- Store headers in TUI server config -- Pass headers to `InspectorClient` via `MCPServerConfig.headers` -- Consider masking sensitive header values in the UI - -**Code References:** - -- Web client: `web/src/components/CustomHeaders.tsx` - Header management UI component -- Web client: `web/src/lib/hooks/useConnection.ts` (lines 453-514) - Header processing and transport creation -- `InspectorClient`: `core/mcp/config.ts` (lines 118-129) - Headers in `MCPServerConfig` -- `InspectorClient`: `core/mcp/transport.ts` (lines 100-134) - Headers passed to SDK transports - -## Implementation Priority - -### High Priority (Core MCP Features) - -1. **OAuth** - Required for many MCP servers, critical for production use -2. **Sampling** - Core MCP capability, enables LLM sampling workflows -3. **Elicitation** - Core MCP capability, enables interactive workflows -4. **Tasks** - Core MCP capability (v2025-11-25), enables long-running operations without timeouts - āœ… **COMPLETED** in InspectorClient - -### Medium Priority (Enhanced Features) - -5. **Resource Subscriptions** - Useful for real-time resource updates -6. **Completions** - Enhances UX for form filling -7. **Custom Headers** - Useful for custom authentication schemes -8. **ListChanged Notifications** - Auto-refresh lists when server data changes -9. **Roots Support** - Manage file system access for servers -10. **Progress Tracking** - User feedback during long-running operations -11. **Pagination Support** - Handle large lists efficiently (COMPLETED) - -## InspectorClient Extensions Needed - -Based on this analysis, `InspectorClient` needs the following additions: - -1. **Resource Methods** (some already exist): - - āœ… `readResource(uri, metadata?)` - Already exists - - āœ… `listResourceTemplates()` - Already exists - - āœ… Resource template `list` callback support - Already exists (via `listResources()`) - - āœ… `subscribeToResource(uri)` - **COMPLETED** - - āœ… `unsubscribeFromResource(uri)` - **COMPLETED** - - āœ… `getSubscribedResources()` - **COMPLETED** - - āœ… `isSubscribedToResource(uri)` - **COMPLETED** - - āœ… `supportsResourceSubscriptions()` - **COMPLETED** - - āœ… Resource / resource template / prompt / tool-call result access - **COMPLETED** (via InspectorClient RPCs; list and optional content caching are in state managers or app layer; see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)) - -2. **Sampling Support**: - - āœ… `getPendingSamples()` - Already exists - - āœ… `removePendingSample(id)` - Already exists - - āœ… `SamplingCreateMessage.respond(result)` - Already exists - - āœ… `SamplingCreateMessage.reject(error)` - Already exists - - āœ… Automatic request handler setup - Already exists - - āœ… `sampling: {}` capability declaration - Already exists (via `sample` option) - -3. **Elicitation Support**: - - āœ… `getPendingElicitations()` - Already exists - - āœ… `removePendingElicitation(id)` - Already exists - - āœ… `ElicitationCreateMessage.respond(result)` - Already exists - - āœ… Automatic request handler setup - Already exists - - āœ… `elicitation: {}` capability declaration - Already exists (via `elicit` option) - -4. **Completion Support**: - - āœ… `getCompletions(ref, argumentName, argumentValue, context?, metadata?)` - Already exists - - āœ… Supports resource template completions - Already exists - - āœ… Supports prompt argument completions - Already exists - - āŒ Integration into TUI `ResourceTestModal` for template variable completion - - āŒ Integration into TUI `PromptTestModal` for prompt argument completion - -5. **OAuth Support**: - - āœ… OAuth token management (shared auth, configurable storage) - - āœ… OAuth flow initiation (`authenticate()`, `authenticateGuided()`, `completeOAuthFlow()`) - - āœ… Token injection via `authProvider` on HTTP transports - - āœ… TUI integration and UI (OAuth 2.1, static client, CIMD, DCR, guided auth, browser-based flow, localhost callback server) - -6. **ListChanged Notifications**: - - āœ… Notification handlers for `notifications/tools/list_changed` - **COMPLETED** - - āœ… Notification handlers for `notifications/resources/list_changed` - **COMPLETED** - - āœ… Notification handlers for `notifications/prompts/list_changed` - **COMPLETED** - - āœ… Auto-refresh lists when notifications received - **COMPLETED** - - āœ… Configurable via `listChangedNotifications` option - **COMPLETED** - - āœ… Cache preservation and cleanup - **COMPLETED** - -7. **Roots Support**: - - āœ… `getRoots()` method - Already exists - - āœ… `setRoots(roots)` method - Already exists - - āœ… Handler for `roots/list` requests - Already exists - - āœ… Notification handler for `notifications/roots/list_changed` - Already exists - - āœ… `roots: { listChanged: true }` capability declaration - Already exists (when `roots` option provided) - - āŒ Integration into TUI for managing roots - -8. **Pagination Support**: - - āœ… Cursor parameter support in `listResources()` - **COMPLETED** - - āœ… Cursor parameter support in `listResourceTemplates()` - **COMPLETED** - - āœ… Cursor parameter support in `listPrompts()` - **COMPLETED** - - āœ… Cursor parameter support in `listTools()` - **COMPLETED** - - āœ… Return `nextCursor` from list methods - **COMPLETED** - - āœ… Pagination helper methods (`listAll*()`) - **COMPLETED** - -9. **Progress Tracking**: - - āœ… Progress notification handling - Implemented (dispatches `progressNotification` events) - - āœ… Progress token support - Implemented (accepts `progressToken` in metadata) - - āœ… Event-based API - Clients listen for `progressNotification` events (no callbacks needed) - - āœ… Timeout reset on progress - Per-request `onprogress` when progress enabled; `resetTimeoutOnProgress` and `timeout` honored - -## Notes - -- **HTTP Request Tracking**: InspectorClient dispatches `fetchRequest` events (per entry); **FetchRequestLogState** (in `core/mcp/state/`) holds the list and emits `fetchRequestsChange`. TUI displays these in a `RequestsTab`. Web client can use the same state manager. See [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md). -- **Resource Subscriptions**: Web client supports this, but TUI does not. InspectorClient supports resource subscriptions with `subscribeToResource()`, `unsubscribeFromResource()`, and handling of `notifications/resources/updated`. -- **OAuth**: Web client has full OAuth support. TUI now supports OAuth 2.1 including static client, CIMD, DCR, and guided auth, with browser-based flow and localhost callback server. -- **Completions**: InspectorClient has full completion support via `getCompletions()`. Web client uses this for resource template forms and prompt parameter forms. TUI has both resource template forms and prompt parameter forms, but completion support is still needed to provide autocomplete suggestions. -- **Sampling**: InspectorClient has full sampling support. Web client UI displays and handles sampling requests. TUI needs UI to display and handle sampling requests. -- **Elicitation**: InspectorClient has full elicitation support. Web client UI displays and handles elicitation requests. TUI needs UI to display and handle elicitation requests. -- **ListChanged Notifications**: List and cache state live in **state managers** (see [protocol-and-state-managers-architecture.md](protocol-and-state-managers-architecture.md)). InspectorClient dispatches signal events (`toolsListChanged`, etc.); state managers subscribe, call list RPCs, hold lists, and dispatch `toolsChange`/etc. Web client and TUI use state managers (or equivalent) to refresh lists when notifications are received. -- **Roots**: InspectorClient has full roots support with `getRoots()` and `setRoots()` methods, handler for `roots/list` requests, and notification support. Web client has a `RootsTab` UI for managing roots. TUI does not yet have UI for managing roots. -- **Pagination**: InspectorClient exposes list RPCs with cursor params and optional `nextCursor`. **State managers** (Paged* and Managed*) handle pagination and list state. Web client uses these; TUI can use the same managers. -- **Progress Tracking**: Web client supports progress tracking by generating `progressToken`, using `onprogress` callbacks, and displaying progress notifications. InspectorClient passes per-request `onprogress` when progress is enabled (so timeout reset is honored), collects `progressToken` from metadata, injects it only into dispatched `progressNotification` events (not sent to server), and passes `resetTimeoutOnProgress`/`timeout` through. TUI does not yet have UI support for displaying progress notifications. -- **Tasks**: Tasks (SEP-1686) were introduced in MCP version 2025-11-25 to support long-running operations. InspectorClient supports tasks (e.g. `callToolStream()`, task RPCs, signal events). **Requestor task list** state is in optional state managers (e.g. PagedRequestorTasksState). Web client supports tasks; TUI does not yet have UI for task management. See [Task Support Design](./task-support-design.md) for implementation details. - -## Related Documentation - -- [Shared Code Architecture](./shared-code-architecture.md) - Overall architecture and integration plan -- [InspectorClient Details](./inspector-client-details.svg) - Visual diagram of InspectorClient responsibilities -- [Task Support Design](./task-support-design.md) - Design and implementation plan for Task support -- [MCP Clients Feature Support](https://modelcontextprotocol.info/docs/clients/) - High-level overview of MCP feature support across different clients - -## OAuth in TUI - -Hosted everything test server: https://example-server.modelcontextprotocol.io/mcp - -- Works from web client and TUI -- Determine if it's using DCR or CIMD - - Whichever one, find server that uses the other - -GitHub: https://github.com/github/github-mcp-server/ - -- This fails discovery in web client - appears related to CORS: https://github.com/modelcontextprotocol/inspector/issues/995 -- Test in TUI - -Guided auth - -- Try it in web ux to see how it works - - Record steps and output at each step -- Implement in TUI - -Let's make the "OAuth complete. You can close this window." page a little fancier - -Auth step change give prev/current step, use client.getOAuthState() to get current state (is automatically update as state machine progresses) - -Guided: - -| previousStep | step | state (payload — delta for this transition) | -| ------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `metadata_discovery` | `client_registration` | `resourceMetadata?`, `resource?`, `resourceMetadataError?`, `authServerUrl`, `oauthMetadata`, `oauthStep: "client_registration"` | -| `client_registration` | `authorization_redirect` | `oauthClientInfo`, `oauthStep: "authorization_redirect"` | -| `authorization_redirect` | `authorization_code` | `authorizationUrl`, `oauthStep: "authorization_code"` | -| `authorization_code` | `token_request` | `validationError: null` (or error string if code missing), `oauthStep: "token_request"` | -| `token_request` | `complete` | `oauthTokens`, `oauthStep: "complete"` | - -Normal: - -| When | oauthStep | getOAuthState() — populated fields | -| ------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------- | -| Before `authenticate()` | — | `undefined` (no state) | -| After `authenticate()` returns | `authorization_code` | `authType: "normal"`, `oauthStep: "authorization_code"`, `authorizationUrl`, `oauthClientInfo` | -| After `completeOAuthFlow()` succeeds | `complete` | `oauthStep: "complete"`, `oauthTokens`, `completedAt`. | - -Discovery fields (`resourceMetadata`, `oauthMetadata`, `authServerUrl`, `resource`) are null. - -Look at how web client displays Auth info (tab?) - -- We might want to have am Auth tab to show auth details - - Will differ between normal and guided (per above tables) - -## Issues - -Web client / proxy - -When attempting to auth to GitHub, it failed to be able to read the auth server metatata due to CORS - -- auth takes a fetch function for this purpose, and that fetch funciton needs to run in Node (not the browser) for this to work - -When attempting to connect in direct mode to the hosted "everything" server it failed because a CORS issue blocked the mcp-session-id response header from the initialize message - -- This can be addressed by running in proxy mode diff --git a/docs/web-client-port-plan.md b/docs/web-client-port-plan.md deleted file mode 100644 index e36418148..000000000 --- a/docs/web-client-port-plan.md +++ /dev/null @@ -1,1896 +0,0 @@ -# Web Client Port to InspectorClient - Step-by-Step Plan - -## Overview - -This document provides a step-by-step plan for porting the `web/` application to use `InspectorClient` instead of `useConnection`, integrating the Hono remote API server directly into Vite (eliminating the separate Express server), and ensuring functional parity with the existing `client/` application. - -**Goal:** The `web/` app should function identically to `client/` but use `InspectorClient` and the integrated Hono server instead of `useConnection` and the separate Express proxy. - -## Progress Summary - -- āœ… **Phase 1:** Integrate Hono Server into Vite - **COMPLETE** -- āœ… **Phase 2:** Create Web Client Adapter - **COMPLETE** -- āœ… **Phase 3:** Replace useConnection with InspectorClient - **COMPLETE** (All steps complete) - cd web- āœ… **Phase 4:** OAuth Integration - **COMPLETE** (All OAuth tests rewritten and passing) -- āœ… **Phase 5:** Remove Express Server Dependency - **COMPLETE** (Express proxy completely removed, Hono server handles all functionality) -- āøļø **Phase 6:** Testing and Validation - **IN PROGRESS** (Unit tests passing, integration testing remaining) -- āøļø **Phase 7:** Cleanup - **PARTIALLY COMPLETE** (useConnection removed, console.log cleaned up) - -**Current Status:** Core InspectorClient integration complete. OAuth integration complete with all tests rewritten. Express proxy completely removed. All unit tests passing (396 tests). Remaining work: Integration testing (Phase 6) and final cleanup (Phase 7). - -**Reference Documents:** - -- [Environment Isolation](./environment-isolation.md) - Details on remote infrastructure and seams -- [Shared Code Architecture](./shared-code-architecture.md) - High-level architecture and integration strategy -- [TUI Web Client Feature Gaps](./tui-web-client-feature-gaps.md) - Feature comparison - ---- - -## Phase 1: Integrate Hono Server into Vite āœ… COMPLETE - -**Goal:** Integrate the Hono remote API server into Vite (dev) and create a production server, making `/api/*` endpoints available. The web app will continue using the existing proxy/useConnection during this phase, allowing us to validate that the new API endpoints are working before migrating the app to use them. - -**Status:** āœ… Complete - Hono server integrated into Vite dev mode and production server created. Both Express proxy and Hono server run simultaneously. - -**Validation:** After Phase 1, you should be able to: - -- Start the dev server: Vite serves static files + Hono middleware handles `/api/*` routes, Express proxy runs separately -- Start the production server: Hono server (`bin/server.js`) serves static files + `/api/*` routes, Express proxy runs separately -- The existing web app continues to work normally in both dev and prod (still uses Express proxy for API calls) -- Hono endpoints (`/api/*`) are available and can be tested, but web app doesn't use them yet - ---- - -### Step 1.1: Create Vite Plugin for Hono Middleware āœ… COMPLETE - -**File:** `web/vite.config.ts` - -**Status:** āœ… Complete - -Create a Vite plugin that adds Hono middleware to handle `/api/*` routes. This runs alongside the existing Express proxy server (which the web app still uses). - -**As-Built:** - -- Implemented `honoMiddlewarePlugin` that mounts Hono middleware at root and checks for `/api` prefix -- Fixed Connect middleware path stripping issue by mounting at root and checking path manually -- Auth token passed via `process.env.MCP_INSPECTOR_API_TOKEN` (read-only, set by start script) - -```typescript -import { defineConfig, Plugin } from "vite"; -import react from "@vitejs/plugin-react"; -import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; -import { randomBytes } from "node:crypto"; -import type { ConnectMiddleware } from "vite"; - -function honoMiddlewarePlugin(authToken: string): Plugin { - return { - name: "hono-api-middleware", - configureServer(server) { - // createRemoteApp returns { app, authToken } - we pass authToken explicitly - // If not provided, it will read from env or generate one - const { app: honoApp, authToken: resolvedAuthToken } = createRemoteApp({ - authToken, // Pass token explicitly (from start script) - storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: [ - `http://localhost:${process.env.CLIENT_PORT || "6274"}`, - `http://127.0.0.1:${process.env.CLIENT_PORT || "6274"}`, - ], - logger: process.env.MCP_LOG_FILE - ? createFileLogger({ logPath: process.env.MCP_LOG_FILE }) - : undefined, - }); - - // Store resolved token for potential use (though we already have it) - // This ensures we use the same token that createRemoteApp is using - const finalAuthToken = authToken || resolvedAuthToken; - - // Convert Connect middleware to handle Hono app - const honoMiddleware: ConnectMiddleware = async (req, res, next) => { - try { - // Convert Node req/res to Web Standard Request - const url = `http://${req.headers.host}${req.url}`; - const headers = new Headers(); - Object.entries(req.headers).forEach(([key, value]) => { - if (value) { - headers.set(key, Array.isArray(value) ? value.join(", ") : value); - } - }); - - const init: RequestInit = { - method: req.method, - headers, - }; - - // Handle body for non-GET requests - if (req.method !== "GET" && req.method !== "HEAD") { - const chunks: Buffer[] = []; - req.on("data", (chunk) => chunks.push(chunk)); - await new Promise((resolve) => { - req.on("end", () => resolve()); - }); - if (chunks.length > 0) { - init.body = Buffer.concat(chunks); - } - } - - const request = new Request(url, init); - const response = await honoApp.fetch(request); - - // Convert Web Standard Response back to Node res - res.statusCode = response.status; - response.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - - if (response.body) { - const reader = response.body.getReader(); - const pump = async () => { - const { done, value } = await reader.read(); - if (done) { - res.end(); - } else { - res.write(Buffer.from(value)); - await pump(); - } - }; - await pump(); - } else { - res.end(); - } - } catch (error) { - next(error); - } - }; - - server.middlewares.use("/api", honoMiddleware); - }, - }; -} - -export default defineConfig({ - plugins: [ - react(), - // Auth token is passed via env var (read-only, set by start script) - // Vite plugin reads it and passes explicitly to createRemoteApp - honoMiddlewarePlugin(process.env.MCP_INSPECTOR_API_TOKEN || ""), - ], - // ... rest of config -}); -``` - -**Dependencies needed:** - -- `@modelcontextprotocol/inspector-core` (already in workspace) -- `node:crypto` for `randomBytes` - -**Auth Token Handling:** - -The canonical environment variable for the API/auth token is `MCP_INSPECTOR_API_TOKEN`. `MCP_PROXY_AUTH_TOKEN` is accepted for backward compatibility. If neither is set, a random token is generated. - -The auth token flow: - -1. **Start script (`bin/start.js`)**: Reads `process.env.MCP_INSPECTOR_API_TOKEN` (or `MCP_PROXY_AUTH_TOKEN`) or generates one -2. **Vite plugin**: Receives token via env var (read-only, passed to spawned process). Plugin reads it and passes explicitly to `createRemoteApp()` -3. **Client browser**: Receives token via URL params (`?MCP_INSPECTOR_API_TOKEN=...`) - -**Key principle:** We never write to `process.env` to pass values between our own code. The token is: - -- Generated/read once in the start script -- Passed explicitly to Vite via env var (read-only, for the spawned process) -- Passed explicitly to `createRemoteApp()` via function parameter -- Passed to client via URL params - -**Testing:** - -- Start dev server: `npm run dev` (this will start both Vite with Hono middleware AND the Express proxy) -- Verify `/api/mcp/connect` endpoint responds (should return 401 without auth token) - - Test: `curl http://localhost:6274/api/mcp/connect` (should return 401) -- Verify `/api/fetch` endpoint exists: `curl http://localhost:6274/api/fetch` -- Verify `/api/log` endpoint exists: `curl http://localhost:6274/api/log` -- Check browser console for errors (should be none - web app still uses Express proxy) -- Verify auth token is passed to client via URL params (for future use) -- **Important:** The web app should still work normally using the Express proxy - we're just validating the new endpoints exist - ---- - -### Step 1.2: Create Production Server āœ… COMPLETE - -**File:** `web/bin/server.js` (new file) - -**Status:** āœ… Complete - -Create a production server that serves static files and API routes: - -**As-Built:** - -- Created `web/bin/server.js` that serves static files and routes `/api/*` to `apiApp` -- Static files served without authentication, API routes require auth token -- Auth token read from `process.env.MCP_INSPECTOR_API_TOKEN` - -```typescript -#!/usr/bin/env node - -import { serve } from "@hono/node-server"; -import { serveStatic } from "@hono/node-server/serve-static"; -import { Hono } from "hono"; -import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import { randomBytes } from "node:crypto"; -import { createFileLogger } from "@modelcontextprotocol/inspector-core/mcp/node/logger"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const distPath = join(__dirname, "../dist"); - -const app = new Hono(); - -// Read auth token from env (provided by start script via spawn env) -// createRemoteApp will use this, or generate one if not provided -// The token is passed explicitly from start script, not written to process.env -const authToken = - process.env.MCP_INSPECTOR_API_TOKEN || randomBytes(32).toString("hex"); - -// Note: createRemoteApp returns the authToken it uses, so we could also -// let it generate one and return it, but for consistency we generate/read it here - -// Add API routes first (more specific) -const port = parseInt(process.env.CLIENT_PORT || "6274", 10); -const host = process.env.HOST || "localhost"; -const baseUrl = `http://${host}:${port}`; - -const { app: apiApp } = createRemoteApp({ - authToken, - storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") || [baseUrl], - logger: process.env.MCP_LOG_FILE - ? createFileLogger({ logPath: process.env.MCP_LOG_FILE }) - : undefined, -}); -app.route("/api", apiApp); - -// Then add static file serving (fallback for SPA routing) -app.use( - "/*", - serveStatic({ - root: distPath, - rewriteRequestPath: (path) => { - // If path doesn't exist and doesn't have extension, serve index.html (SPA routing) - if (!path.includes(".") && !path.startsWith("/api")) { - return "/index.html"; - } - return path; - }, - }), -); - -serve( - { - fetch: app.fetch, - port, - hostname: host, - }, - (info) => { - console.log( - `\nšŸš€ MCP Inspector Web is up and running at:\n http://${host}:${info.port}\n`, - ); - console.log(` Auth token: ${authToken}\n`); - }, -); -``` - -**Update `web/package.json`:** - -- Add `start` script: `"start": "node bin/server.js"` -- Ensure `bin/server.js` is executable (chmod +x) - -**Dependencies needed:** - -- `@hono/node-server` (add to `web/package.json`) - -**Testing:** - -- Build: `npm run build` -- Start: `npm start` (via `bin/start.js` - starts Hono server for static files + `/api/*` endpoints, AND Express proxy) -- Verify static files serve correctly: `curl http://localhost:6274/` (should return index.html from Hono server) -- Verify `/api/mcp/connect` endpoint works: `curl http://localhost:6274/api/mcp/connect` (should return 401) -- Verify `/api/fetch` endpoint exists: `curl http://localhost:6274/api/fetch` -- Verify `/api/log` endpoint exists: `curl http://localhost:6274/api/log` -- Verify auth token is logged/available -- **Note:** Both Hono server (serving static files) and Express proxy run simultaneously. Web app uses Express proxy for API calls, but static files come from Hono server. - ---- - -### Step 1.3: Update Start Script (Keep Express Proxy for Now) āœ… COMPLETE - -**File:** `web/bin/start.js` - -**Status:** āœ… Complete - -**Important:** During Phase 1, both servers run: - -**As-Built:** - -- Start script generates both `proxySessionToken` (for Express) and `honoAuthToken` (for Hono) -- Tokens passed explicitly via environment variables (for spawned processes) and URL params (for browser) -- Both Express proxy and Vite/Hono server run simultaneously in dev/prod mode - -- **Hono server**: Serves static files (dev: via Vite middleware, prod: via `bin/server.js`) + `/api/*` endpoints -- **Express proxy**: Handles web app API calls (`/mcp`, `/stdio`, `/sse`, etc.) -- Web app loads static files from Hono server but makes API calls to Express proxy - -**Changes:** - -1. **Keep server spawning functions (for now):** - - Keep `startDevServer()` function (spawns Express proxy) - - Keep `startProdServer()` function (spawns Express proxy) - - Both Hono (via Vite middleware) and Express will run simultaneously in dev mode - -2. **Update `startDevClient()` to pass auth token to Vite:** - - ```typescript - async function startDevClient(clientOptions) { - const { CLIENT_PORT, honoAuthToken, abort, cancelled } = clientOptions; - const clientCommand = "npx"; - const host = process.env.HOST || "localhost"; - const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; - - const client = spawn(clientCommand, clientArgs, { - cwd: resolve(__dirname, ".."), - env: { - ...process.env, - CLIENT_PORT, - MCP_INSPECTOR_API_TOKEN: honoAuthToken, // Pass token to Vite (read-only) - // Note: Express proxy still uses MCP_PROXY_AUTH_TOKEN (different token) - }, - signal: abort.signal, - echoOutput: true, - }); - - // Include auth token in URL for client (Phase 3 will use this) - const params = new URLSearchParams(); - params.set("MCP_INSPECTOR_API_TOKEN", honoAuthToken); - const url = `http://${host}:${CLIENT_PORT}/?${params.toString()}`; - - setTimeout(() => { - console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); - console.log( - ` Static files served by: Vite (dev) / Hono server (prod)\n`, - ); - console.log(` Hono API endpoints: ${url}/api/*\n`); - console.log( - ` Express proxy: http://localhost:${SERVER_PORT} (web app API calls)\n`, - ); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log("🌐 Opening browser..."); - open(url); - } - }, 3000); - - await new Promise((resolve) => { - client.subscribe({ - complete: resolve, - error: (err) => { - if (!cancelled || process.env.DEBUG) { - console.error("Client error:", err); - } - resolve(null); - }, - next: () => {}, - }); - }); - } - ``` - - **Note:** In dev mode, both servers run: - - Express proxy: `http://localhost:6277` (web app uses this) - - Hono API (via Vite): `http://localhost:6274/api/*` (available for validation) - -3. **Update `startProdClient()` - Use Hono server for static files:** - - ```typescript - async function startProdClient(clientOptions) { - const { CLIENT_PORT, honoAuthToken, abort } = clientOptions; - const honoServerPath = resolve(__dirname, "bin", "server.js"); - - // Hono server serves static files + /api/* endpoints - // Pass auth token explicitly via env var (read-only, server reads it) - await spawnPromise("node", [honoServerPath], { - env: { - ...process.env, - CLIENT_PORT, - MCP_INSPECTOR_API_TOKEN: honoAuthToken, // Pass token explicitly - }, - signal: abort.signal, - echoOutput: true, - }); - } - ``` - - **Note:** In Phase 1, prod mode uses Hono server to serve static files (just like Vite does in dev mode). Express proxy still runs separately for API calls. Web app loads static files from Hono server but makes API calls to Express proxy. Auth token is passed explicitly via env var (read-only). - -4. **Update `main()` function to run both servers in dev mode:** - - ```typescript - async function main() { - // ... parse args (same as before) ... - - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - const SERVER_PORT = - process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT; - - // Generate auth tokens (separate tokens for Express proxy and Hono API) - const proxySessionToken = - process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); - const honoAuthToken = - process.env.MCP_INSPECTOR_API_TOKEN || randomBytes(32).toString("hex"); - - const abort = new AbortController(); - let cancelled = false; - process.on("SIGINT", () => { - cancelled = true; - abort.abort(); - }); - - let server, serverOk; - - if (isDev) { - // In dev mode: start Express proxy (web app uses this) AND Vite with Hono middleware - try { - const serverOptions = { - SERVER_PORT, - CLIENT_PORT, - sessionToken: proxySessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - }; - - const result = await startDevServer(serverOptions); - server = result.server; - serverOk = result.serverOk; - } catch (error) { - // Continue even if Express proxy fails - Hono API still works - console.warn("Express proxy failed to start:", error); - serverOk = false; - } - - if (serverOk) { - // Start Vite with Hono middleware (runs alongside Express proxy) - try { - const clientOptions = { - CLIENT_PORT, - SERVER_PORT, - honoAuthToken, // Pass Hono auth token explicitly - abort, - cancelled, - }; - await startDevClient(clientOptions); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } - } - } else { - // In prod mode: start Express proxy (web app uses this) AND Hono server - try { - const serverOptions = { - SERVER_PORT, - CLIENT_PORT, - sessionToken: proxySessionToken, - envVars, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - }; - - const result = await startProdServer(serverOptions); - server = result.server; - serverOk = result.serverOk; - } catch (error) { - console.warn("Express proxy failed to start:", error); - serverOk = false; - } - - if (serverOk) { - // Start Hono server (serves static files + /api/* endpoints) - try { - const clientOptions = { - CLIENT_PORT, - honoAuthToken, // Pass token explicitly - abort, - cancelled, - }; - await startProdClient(clientOptions); - } catch (e) { - if (!cancelled || process.env.DEBUG) throw e; - } - } - - // Both servers run: - // - Hono server (via startProdClient) serves static files + /api/* endpoints - // - Express proxy (via startProdServer) handles web app API calls - } - - return 0; - } - ``` - - **Key points:** - - In dev mode: Both Express proxy (port 6277) and Hono API (port 6274/api/\*) run simultaneously - - Web app continues using Express proxy (no changes needed yet) - - Hono API endpoints are available for validation/testing - - Separate auth tokens: `MCP_PROXY_AUTH_TOKEN` (Express) and `MCP_INSPECTOR_API_TOKEN` (Hono) - ---- - -## Phase 2: Create Web Client Adapter āœ… COMPLETE - -### Step 2.1: Create Config to MCPServerConfig Adapter āœ… COMPLETE - -**File:** `web/src/lib/adapters/configAdapter.ts` (new file) - -**Status:** āœ… Complete - -**Existing Code Reference:** - -- `client/src/components/Sidebar.tsx` has `generateServerConfig()` (lines 137-160) that converts web client format, but it's missing `type: "stdio"` and doesn't handle `customHeaders` -- `shared/mcp/node/config.ts` has `argsToMcpServerConfig()` for CLI format, but not web client format - -Create an adapter that converts the web client's configuration format to `MCPServerConfig`: - -```typescript -import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/types"; -import type { CustomHeaders } from "../types/customHeaders"; - -export function webConfigToMcpServerConfig( - transportType: "stdio" | "sse" | "streamable-http", - command?: string, - args?: string, - sseUrl?: string, - env?: Record, - customHeaders?: CustomHeaders, -): MCPServerConfig { - switch (transportType) { - case "stdio": { - if (!command) { - throw new Error("Command is required for stdio transport"); - } - const config: MCPServerConfig = { - type: "stdio", - command, - }; - if (args?.trim()) { - config.args = args.split(/\s+/); - } - if (env && Object.keys(env).length > 0) { - config.env = env; - } - return config; - } - case "sse": { - if (!sseUrl) { - throw new Error("SSE URL is required for SSE transport"); - } - const headers: Record = {}; - customHeaders?.forEach((header) => { - if (header.enabled) { - headers[header.name] = header.value; - } - }); - const config: MCPServerConfig = { - type: "sse", - url: sseUrl, - }; - if (Object.keys(headers).length > 0) { - config.headers = headers; - } - return config; - } - case "streamable-http": { - if (!sseUrl) { - throw new Error("Server URL is required for streamable-http transport"); - } - const headers: Record = {}; - customHeaders?.forEach((header) => { - if (header.enabled) { - headers[header.name] = header.value; - } - }); - const config: MCPServerConfig = { - type: "streamable-http", - url: sseUrl, - }; - if (Object.keys(headers).length > 0) { - config.headers = headers; - } - return config; - } - } -} -``` - -**Note:** This is similar to `generateServerConfig()` in `Sidebar.tsx` but: - -- Adds `type: "stdio"` for stdio transport -- Converts `customHeaders` array to `headers` object (only enabled headers) -- Returns proper `MCPServerConfig` type (no `note` field) - ---- - -### Step 2.2: Create Environment Factory āœ… COMPLETE - -**File:** `web/src/lib/adapters/environmentFactory.ts` (new file) - -**Status:** āœ… Complete - -Create a factory function that builds the `InspectorClientEnvironment` object: - -**As-Built:** - -- Fixed "Illegal invocation" error by wrapping `window.fetch` to preserve `this` context: `const fetchFn: typeof fetch = (...args) => globalThis.fetch(...args)` -- Uses `BrowserOAuthStorage` and `BrowserNavigation` for OAuth -- `redirectUrlProvider` consistently returns `/oauth/callback` regardless of mode (mode stored in state, not URL) - -```typescript -import type { InspectorClientEnvironment } from "@modelcontextprotocol/inspector-core/mcp/inspectorClient"; -import { createRemoteTransport } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteTransport"; -import { createRemoteFetch } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteFetch"; -import { createRemoteLogger } from "@modelcontextprotocol/inspector-core/mcp/remote/createRemoteLogger"; -import { BrowserOAuthStorage } from "@modelcontextprotocol/inspector-core/auth/browser"; -import { BrowserNavigation } from "@modelcontextprotocol/inspector-core/auth/browser"; -import type { RedirectUrlProvider } from "@modelcontextprotocol/inspector-core/auth/types"; - -export function createWebEnvironment( - authToken: string | undefined, - redirectUrlProvider: RedirectUrlProvider, -): InspectorClientEnvironment { - const baseUrl = `${window.location.protocol}//${window.location.host}`; - - return { - transport: createRemoteTransport({ - baseUrl, - authToken, - fetchFn: window.fetch, - }), - fetch: createRemoteFetch({ - baseUrl, - authToken, - fetchFn: window.fetch, - }), - logger: createRemoteLogger({ - baseUrl, - authToken, - fetchFn: window.fetch, - }), - oauth: { - storage: new BrowserOAuthStorage(), // or RemoteOAuthStorage for shared state - navigation: new BrowserNavigation(), - redirectUrlProvider, - }, - }; -} -``` - -**Note:** Consider using `RemoteOAuthStorage` if you want shared OAuth state with TUI/CLI. The auth token should come from the Hono server (same token used to create the server). - ---- - -## Phase 3: Replace useConnection with InspectorClient āœ… COMPLETE - -### Step 3.1: Understand useInspectorClient Interface āœ… COMPLETE - -**Reference:** `shared/react/useInspectorClient.ts` - -**Status:** āœ… Complete - Hook interface understood and used throughout implementation - -The `useInspectorClient` hook returns: - -```typescript -interface UseInspectorClientResult { - status: ConnectionStatus; // 'disconnected' | 'connecting' | 'connected' | 'error' - messages: MessageEntry[]; - stderrLogs: StderrLogEntry[]; - fetchRequests: FetchRequestEntry[]; - tools: Tool[]; - resources: Resource[]; - resourceTemplates: ResourceTemplate[]; - prompts: Prompt[]; - capabilities?: ServerCapabilities; - serverInfo?: Implementation; - instructions?: string; - client: Client | null; // The underlying MCP SDK Client - connect: () => Promise; - disconnect: () => Promise; -} -``` - -**Note:** The hook uses `status` (not `connectionStatus`). You'll need to map this when replacing `useConnection` calls in components. - ---- - -### Step 3.2: Update App.tsx to Use InspectorClient āœ… COMPLETE - -**File:** `web/src/App.tsx` - -**Status:** āœ… Complete - -**Changes:** - -**As-Built:** - -- Removed local state syncing (`useEffect` blocks) for resources, prompts, tools, resourceTemplates -- Removed local state declarations - now using hook values directly (`inspectorResources`, `inspectorPrompts`, `inspectorTools`, `inspectorResourceTemplates`) -- Updated all component props to use hook values -- InspectorClient instance created in `useMemo` with proper dependencies -- Auth token extracted from URL params (`MCP_INSPECTOR_API_TOKEN`) -- `useInspectorClient` hook used to get all state and methods - -1. **Replace imports:** - - ```typescript - // Remove - import { useConnection } from "./lib/hooks/useConnection"; - - // Add - import { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/inspectorClient"; - import { useInspectorClientWeb } from "./lib/hooks/useInspectorClientWeb"; - import { createWebEnvironment } from "./lib/adapters/environmentFactory"; - import { webConfigToMcpServerConfig } from "./lib/adapters/configAdapter"; - ``` - -2. **Get auth token and create InspectorClient instance:** - - ```typescript - // Get auth token from URL params (set by start script) or localStorage - const authToken = useMemo(() => { - const params = new URLSearchParams(window.location.search); - return params.get("MCP_INSPECTOR_API_TOKEN") || null; - }, []); - - const inspectorClient = useMemo(() => { - if (!command && !sseUrl) return null; // Can't create without config - if (!authToken) return null; // Need auth token for remote API - - const config = webConfigToMcpServerConfig( - transportType, - command, - args, - sseUrl, - env, - customHeaders, - ); - - const environment = createWebEnvironment(authToken, () => { - return `${window.location.origin}/oauth/callback`; - }); - - return new InspectorClient(config, { - environment, - autoFetchServerContents: true, // Match current behavior - maxMessages: 1000, - maxStderrLogEvents: 1000, - maxFetchRequests: 1000, - oauth: { - clientId: oauthClientId || undefined, - clientSecret: oauthClientSecret || undefined, - scope: oauthScope || undefined, - }, - }); - }, [ - transportType, - command, - args, - sseUrl, - env, - customHeaders, - oauthClientId, - oauthClientSecret, - oauthScope, - authToken, - ]); - ``` - -3. **Replace useConnection hook:** - - ```typescript - // Remove - const { connectionStatus, ... } = useConnection({ ... }); - - // Add - const { - status: connectionStatus, // Map 'status' to 'connectionStatus' for compatibility - tools, - resources, - prompts, - messages, - stderrLogs, - fetchRequests, - capabilities, - serverInfo, - instructions, - client: mcpClient, - connect: connectMcpServer, - disconnect: disconnectMcpServer, - } = useInspectorClient(inspectorClient); - ``` - -4. **Update connect/disconnect handlers:** - - ```typescript - // These are now provided by useInspectorClient hook: - // connectMcpServer and disconnectMcpServer are already available from the hook - // No need to create separate handlers unless you need custom logic - ``` - -5. **Update OAuth handlers:** - - Replace `useConnection` OAuth methods with `InspectorClient` methods: - - `authenticate()` → `inspectorClient.authenticate()` - - `completeOAuthFlow()` → `inspectorClient.completeOAuthFlow()` - - `getOAuthTokens()` → `inspectorClient.getOAuthTokens()` - ---- - -### Step 3.3: Migrate State Format āœ… COMPLETE - -**File:** `web/src/App.tsx` - -**Status:** āœ… Complete - -**Changes:** - -1. **Message History:** āœ… Complete - - **As-Built:** `requestHistory` now uses MCP protocol messages from `inspectorMessages` - - Filters `inspectorMessages` for `direction === "request"` (non-notification messages) - - Converts to format: `{ request: string, response?: string }[]` for `HistoryAndNotifications` component - - **Note:** History tab shows MCP protocol messages (requests/responses), not HTTP requests - -2. **Request History:** āœ… Complete - - **As-Built:** Not using `FetchRequestEntry[]` - instead using MCP protocol messages for History tab - - `fetchRequests` removed from hook destructuring (not needed for current UI) - -3. **Stderr Logs:** āœ… Complete - - `stderrLogs` destructured from hook and passed to `ConsoleTab` - - `ConsoleTab` displays `StderrLogEntry[]` with timestamps and messages - - **As-Built:** Console tab trigger added to UI, only shown when `transportType === "stdio"` (since stderr logs are only available for stdio transports) - - Console tab added to valid tabs list for routing - -4. **Server Data:** āœ… Complete - - Tools, Resources, Prompts: Using hook values directly (`inspectorTools`, `inspectorResources`, `inspectorPrompts`) - - Manual fetching logic removed - InspectorClient handles this automatically - ---- - -### Step 3.4: Update Notification Handlers āœ… COMPLETE - -**File:** `web/src/App.tsx` - -**Status:** āœ… Complete - -**Changes:** - -1. **Replace notification callbacks:** āœ… Complete - - **As-Built:** Notifications extracted from `inspectorMessages` via `useMemo` + `useEffect` with content comparison to prevent infinite loops: - - ```typescript - const extractedNotifications = useMemo(() => { - return inspectorMessages - .filter((msg) => msg.direction === "notification" && msg.message) - .map((msg) => msg.message as ServerNotification); - }, [inspectorMessages]); - - const previousNotificationsRef = useRef("[]"); - useEffect(() => { - const currentSerialized = JSON.stringify(extractedNotifications); - if (currentSerialized !== previousNotificationsRef.current) { - setNotifications(extractedNotifications); - previousNotificationsRef.current = currentSerialized; - } - }, [extractedNotifications]); - ``` - - - **Bug Fix:** Fixed infinite loop caused by `InspectorClient.getMessages()` returning new array references. Fixed in `useInspectorClient` hook by comparing serialized content before updating state. - - No separate event listeners needed - notifications come from message stream - -2. **Update request handlers:** āœ… Complete - - **Elicitation:** āœ… Complete - Using `inspectorClient.addEventListener("newPendingElicitation", ...)` - - **Sampling:** āœ… Complete - Using `inspectorClient.addEventListener("newPendingSample", ...)` - - **Roots:** āœ… Complete - Using `inspectorClient.getRoots()`, `inspectorClient.setRoots()`, and listening to `rootsChange` event - - `handleRootsChange()` calls `inspectorClient.setRoots(roots)` which handles sending notification internally - - Roots synced with InspectorClient via `useEffect` and `rootsChange` event listener - -3. **Stderr Logs:** āœ… Complete - - **As-Built:** `stderrLogs` destructured from `useInspectorClient` hook - - `ConsoleTab` component updated to accept and display `StderrLogEntry[]` - - Displays timestamp and message for each stderr log entry - - Shows "No stderr output yet" when empty - ---- - -### Step 3.5: Update Method Calls āœ… COMPLETE - -**File:** `web/src/App.tsx` and component files - -**Status:** āœ… Complete - -**Changes:** - -Replace all `mcpClient` method calls with `inspectorClient` methods: - -**As-Built:** - -- āœ… `listResources()` → `inspectorClient.listResources(cursor, metadata)` -- āœ… `listResourceTemplates()` → `inspectorClient.listResourceTemplates(cursor, metadata)` -- āœ… `readResource()` → `inspectorClient.readResource(uri, metadata)` -- āœ… `subscribeToResource()` → `inspectorClient.subscribeToResource(uri)` -- āœ… `unsubscribeFromResource()` → `inspectorClient.unsubscribeFromResource(uri)` -- āœ… `listPrompts()` → `inspectorClient.listPrompts(cursor, metadata)` -- āœ… `getPrompt()` → `inspectorClient.getPrompt(name, args, metadata)` (with JsonValue conversion) -- āœ… `listTools()` → `inspectorClient.listTools(cursor, metadata)` -- āœ… `callTool()` → `inspectorClient.callTool(name, args, generalMetadata, toolSpecificMetadata)` (with ToolCallInvocation → CompatibilityCallToolResult conversion) -- āœ… `sendLogLevelRequest()` → `inspectorClient.setLoggingLevel(level)` -- āœ… Ping → `mcpClient.request({ method: "ping" }, EmptyResultSchema)` (direct SDK call) -- āœ… Removed `sendMCPRequest()` wrapper function -- āœ… Removed `makeRequest()` wrapper function -- āœ… All methods include proper error handling with `clearError()` calls - ---- - -## Phase 4: OAuth Integration - -**Status:** āœ… COMPLETE - -**Goal:** Replace custom OAuth implementation (`InspectorOAuthClientProvider`, `OAuthStateMachine`, manual state management) with `InspectorClient`'s built-in OAuth support, matching the TUI implementation pattern. - ---- - -### Architecture Overview - -**Current State (Web App):** - -- Custom `InspectorOAuthClientProvider` and `DebugInspectorOAuthClientProvider` classes (`web/src/lib/auth.ts`) -- Custom `OAuthStateMachine` class (`web/src/lib/oauth-state-machine.ts`) - duplicates shared implementation -- Manual OAuth state management via `AuthGuidedState` in `App.tsx` -- `OAuthCallback` component uses SDK `auth()` directly -- `AuthDebugger` component uses custom state machine for guided flow -- `OAuthFlowProgress` component displays custom state - -**Target State (After Port):** - -- Use `InspectorClient` OAuth methods: `authenticate()`, `completeOAuthFlow()`, `beginGuidedAuth()`, `proceedOAuthStep()`, `getOAuthState()`, `getOAuthTokens()` -- Use shared `BrowserOAuthStorage` and `BrowserNavigation` (already configured in `createWebEnvironment`) -- Listen to `InspectorClient` OAuth events: `oauthStepChange`, `oauthComplete`, `oauthError` -- Remove custom OAuth providers and state machine -- Simplify components to read from `InspectorClient.getOAuthState()` - -**Reference Implementation (TUI):** - -- TUI uses `InspectorClient` OAuth methods directly -- Quick Auth: Creates callback server → sets redirect URL → calls `authenticate()` → waits for callback → calls `completeOAuthFlow()` -- Guided Auth: Calls `beginGuidedAuth()` → listens to `oauthStepChange` events → calls `proceedOAuthStep()` for each step -- OAuth state synced via `inspectorClient.getOAuthState()` and `oauthStepChange` events - ---- - -### Step 4.1: Follow TUI Pattern - Components Manage OAuth State Directly - -**Approach:** Follow the TUI pattern where components that need OAuth state manage it directly, rather than extending the hook. - -**Rationale:** TUI's `AuthTab` component doesn't use `useInspectorClient` for OAuth state. Instead, it: - -1. Receives `inspectorClient` as a prop -2. Uses `useState` to manage `oauthState` locally -3. Uses `useEffect` to sync state by calling `inspectorClient.getOAuthState()` directly -4. Listens to `oauthStepChange` and `oauthComplete` events directly on `inspectorClient` - -**Alternative Approach (Optional):** We could extend `useInspectorClient` to expose OAuth state, which would be more DRY if multiple components need it. However, since only `AuthDebugger` needs OAuth state in the web app, following the TUI pattern is simpler and more consistent. - -**Implementation Pattern (for AuthDebugger):** - -```typescript -const AuthDebugger = ({ inspectorClient, onBack }: AuthDebuggerProps) => { - const { toast } = useToast(); - const [oauthState, setOauthState] = useState( - undefined, - ); - const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); - - // Sync oauthState from InspectorClient (TUI pattern) - useEffect(() => { - if (!inspectorClient) { - setOauthState(undefined); - return; - } - - const update = () => setOauthState(inspectorClient.getOAuthState()); - update(); - - const onStepChange = () => update(); - inspectorClient.addEventListener("oauthStepChange", onStepChange); - inspectorClient.addEventListener("oauthComplete", onStepChange); - inspectorClient.addEventListener("oauthError", onStepChange); - - return () => { - inspectorClient.removeEventListener("oauthStepChange", onStepChange); - inspectorClient.removeEventListener("oauthComplete", onStepChange); - inspectorClient.removeEventListener("oauthError", onStepChange); - }; - }, [inspectorClient]); - - // OAuth methods call InspectorClient directly - const handleQuickOAuth = useCallback(async () => { - if (!inspectorClient) return; - setIsInitiatingAuth(true); - try { - await inspectorClient.authenticate(); - } catch (error) { - // Handle error - } finally { - setIsInitiatingAuth(false); - } - }, [inspectorClient]); - - const handleGuidedOAuth = useCallback(async () => { - if (!inspectorClient) return; - setIsInitiatingAuth(true); - try { - await inspectorClient.beginGuidedAuth(); - } catch (error) { - // Handle error - } finally { - setIsInitiatingAuth(false); - } - }, [inspectorClient]); - - const proceedToNextStep = useCallback(async () => { - if (!inspectorClient) return; - setIsInitiatingAuth(true); - try { - await inspectorClient.proceedOAuthStep(); - } catch (error) { - // Handle error - } finally { - setIsInitiatingAuth(false); - } - }, [inspectorClient]); - - // ... rest of component uses oauthState ... -}; -``` - -**Benefits of This Approach:** - -- Matches TUI implementation exactly -- No changes needed to shared hook (avoids affecting TUI) -- Simpler - OAuth state only where needed -- Components have direct access to `inspectorClient` methods - -**Note:** If we later need OAuth state in multiple components, we can refactor to extend the hook then. For now, following TUI pattern is the simplest path. - ---- - -### Step 4.2: Update OAuth Callback Component (Single Redirect URL Approach) - -**File:** `web/src/components/OAuthCallback.tsx` - -**Current Implementation:** - -- Uses `InspectorOAuthClientProvider` + SDK `auth()` function -- Reads `serverUrl` from `sessionStorage` -- Calls `onConnect(serverUrl)` after success - -**As-Built Implementation:** - -**Key Design Decision: Single Redirect URL with State Parameter** - -- Uses a single `/oauth/callback` endpoint for both normal and guided modes -- Mode is encoded in the OAuth `state` parameter: `"guided:{random}"` or `"normal:{random}"` -- Matches TUI implementation pattern (no separate debug endpoint) - -**Changes:** - -1. **Remove custom provider imports:** - - ```typescript - // Remove - import { InspectorOAuthClientProvider } from "../lib/auth"; - import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; - import { SESSION_KEYS } from "../lib/constants"; - - // Add - import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; - import { parseOAuthState } from "@modelcontextprotocol/inspector-core/auth/index.js"; - ``` - -2. **Update component props:** - - ```typescript - interface OAuthCallbackProps { - inspectorClient: InspectorClient | null; - ensureInspectorClient: () => InspectorClient | null; - onConnect: () => void; - } - ``` - -3. **Guided Mode Handling (New Tab Scenario):** - - When callback occurs in a new tab without `InspectorClient` context: - - Parse `state` parameter to detect guided mode - - Display authorization code for manual copying - - Store code in `sessionStorage` for potential auto-fill (future enhancement) - - **Do NOT redirect** - user must manually copy code and return to Guided Auth flow - - Message: "Please copy this authorization code and return to the Guided Auth flow:" - -4. **Guided Mode Handling (Same Tab Scenario):** - - When callback occurs in same tab with `InspectorClient` available: - - Call `client.setGuidedAuthorizationCode(code, false)` to set code without auto-completing - - Show toast notification - - **Do NOT redirect** - user controls progression manually - -5. **Normal Mode Handling:** - - Call `client.completeOAuthFlow(code)` to complete flow automatically - - Trigger auto-connect - - Redirect to root (`/`) after completion - -**Key Implementation Details:** - -- Uses `parseOAuthState()` to extract mode from `state` parameter -- Early return for guided mode without client to avoid "API token required" toast -- Remote logging via `InspectorClient.logger` (persists through redirects) -- Handles both `/oauth/callback` and `/` paths (some auth servers redirect incorrectly) - -**Key Changes:** - -- Removed `sessionStorage` dependency (server URL comes from `InspectorClient` config) -- Replaced `InspectorOAuthClientProvider` + `auth()` with `inspectorClient.completeOAuthFlow()` or `setGuidedAuthorizationCode()` -- Single callback endpoint handles both modes via state parameter -- Guided mode shows code for manual copying (no redirect) -- Normal mode auto-completes and redirects - ---- - -### Step 4.3: Remove OAuth Debug Callback Component (Consolidated) - -**File:** `web/src/components/OAuthDebugCallback.tsx` - -**Status:** āœ… Removed - functionality consolidated into `OAuthCallback.tsx` - -**Rationale:** - -- Single redirect URL approach eliminates need for separate debug endpoint -- Guided mode is handled via state parameter in single callback -- Component removed, functionality merged into `OAuthCallback` - ---- - -### Step 4.4: Update App.tsx OAuth Routes and Handlers - -**File:** `web/src/App.tsx` - -**Current Implementation:** - -- Routes `/oauth/callback` and `/oauth/callback/debug` render callback components -- `onOAuthConnect` and `onOAuthDebugConnect` handlers manage state -- OAuth config (clientId, clientSecret, scope) stored in component state - -**As-Built Changes:** - -1. **Single OAuth callback route:** - - ```typescript - // Handle both /oauth/callback and / paths (some auth servers redirect incorrectly) - const hasOAuthCallbackParams = urlParams.has("code") || urlParams.has("error"); - - if ( - window.location.pathname === "/oauth/callback" || - (hasOAuthCallbackParams && window.location.pathname === "/") - ) { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...
}> - - - ); - } - ``` - -2. **Removed `/oauth/callback/debug` route** - consolidated into single callback - -3. **OAuth handlers:** - - `onOAuthConnect`: Calls `connectMcpServer()` after successful OAuth - - Quick Auth handled in `AuthDebugger` component (calls `client.authenticate()`) - - Guided Auth handled in `AuthDebugger` component (calls `client.beginGuidedAuth()`) - -4. **InspectorClient Creation Strategy:** - - Uses `ensureInspectorClient()` helper for lazy creation - - Validates API token before creating client - - Shows toast error if API token missing - - Client created on-demand when needed (connect or auth operations) - -**Key Implementation Details:** - -- Single callback endpoint handles both normal and guided modes -- Callback routing handles both `/oauth/callback` and `/` paths (auth server compatibility) -- `BrowserNavigation` automatically redirects for quick auth -- Guided auth uses manual code entry (no auto-redirect) - ---- - -### Step 4.5: Refactor AuthDebugger Component to Use InspectorClient - -**File:** `web/src/components/AuthDebugger.tsx` - -**Current Implementation:** - -- Uses custom `OAuthStateMachine` and `DebugInspectorOAuthClientProvider` -- Manages `AuthGuidedState` manually via `updateAuthState` -- Has "Guided OAuth Flow" and "Quick OAuth Flow" buttons - -**Changes:** - -1. **Update component props:** - - ```typescript - interface AuthDebuggerProps { - inspectorClient: InspectorClient | null; - onBack: () => void; - } - ``` - -2. **Remove custom state machine and provider:** - - ```typescript - // Remove - import { OAuthStateMachine } from "../lib/oauth-state-machine"; - import { DebugInspectorOAuthClientProvider } from "../lib/auth"; - import type { AuthGuidedState } from "../lib/auth-types"; - - // Add - import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; - import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; - ``` - -3. **Follow TUI pattern - manage OAuth state directly:** - - ```typescript - const AuthDebugger = ({ - inspectorClient, - onBack, - }: AuthDebuggerProps) => { - const { toast } = useToast(); - const [oauthState, setOauthState] = useState( - undefined, - ); - const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); - - // Sync oauthState from InspectorClient (TUI pattern - Step 4.1) - useEffect(() => { - if (!inspectorClient) { - setOauthState(undefined); - return; - } - - const update = () => setOauthState(inspectorClient.getOAuthState()); - update(); - - const onStepChange = () => update(); - inspectorClient.addEventListener("oauthStepChange", onStepChange); - inspectorClient.addEventListener("oauthComplete", onStepChange); - inspectorClient.addEventListener("oauthError", onStepChange); - - return () => { - inspectorClient.removeEventListener("oauthStepChange", onStepChange); - inspectorClient.removeEventListener("oauthComplete", onStepChange); - inspectorClient.removeEventListener("oauthError", onStepChange); - }; - }, [inspectorClient]); - ``` - -4. **Update Quick OAuth handler:** - - ```typescript - const handleQuickOAuth = useCallback(async () => { - if (!inspectorClient) return; - - setIsInitiatingAuth(true); - try { - // Quick Auth: normal flow (automatic redirect via BrowserNavigation) - await inspectorClient.authenticate(); - // BrowserNavigation handles redirect automatically - } catch (error) { - console.error("Quick OAuth failed:", error); - toast({ - title: "OAuth Error", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsInitiatingAuth(false); - } - }, [inspectorClient, toast]); - ``` - -5. **Update Guided OAuth handler:** - - ```typescript - const handleGuidedOAuth = useCallback(async () => { - if (!inspectorClient) return; - - setIsInitiatingAuth(true); - try { - // Start guided flow - await inspectorClient.beginGuidedAuth(); - // State updates via oauthStepChange events (handled in useEffect above) - } catch (error) { - console.error("Guided OAuth start failed:", error); - toast({ - title: "OAuth Error", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsInitiatingAuth(false); - } - }, [inspectorClient, toast]); - ``` - -6. **Update proceed to next step handler:** - - ```typescript - const proceedToNextStep = useCallback(async () => { - const client = ensureInspectorClient(); - if (!client || !oauthState) return; - - setIsInitiatingAuth(true); - try { - await client.proceedOAuthStep(); - // Note: For guided flow, users manually copy the authorization code. - // There's a manual button in OAuthFlowProgress to open the URL if needed. - // Quick auth handles redirects automatically via BrowserNavigation. - } catch (error) { - console.error("OAuth step failed:", error); - toast({ - title: "OAuth Error", - description: error instanceof Error ? error.message : String(error), - variant: "destructive", - }); - } finally { - setIsInitiatingAuth(false); - } - }, [ensureInspectorClient, oauthState, toast]); - ``` - - **Key Change:** Removed auto-opening of authorization URL. In guided flow, users manually copy the code. There's a manual button (external link icon) in `OAuthFlowProgress` at the `authorization_redirect` step if users want to open the URL. - -7. **Update clear OAuth handler:** - - ```typescript - const handleClearOAuth = useCallback(async () => { - const client = ensureInspectorClient(); - if (!client) return; - - client.clearOAuthTokens(); - toast({ - title: "OAuth Cleared", - description: "OAuth tokens have been cleared", - variant: "default", - }); - }, [ensureInspectorClient, toast]); - ``` - - **Note:** Uses `InspectorClient.clearOAuthTokens()` method which clears tokens from storage. - -8. **Update component props and ensureInspectorClient pattern:** - - ```typescript - interface AuthDebuggerProps { - inspectorClient: InspectorClient | null; - ensureInspectorClient: () => InspectorClient | null; - canCreateInspectorClient: () => boolean; - onBack: () => void; - } - ``` - - - Uses `ensureInspectorClient()` helper for lazy client creation - - Validates API token before creating client - - Buttons enabled when `canCreateInspectorClient()` returns true (even if `inspectorClient` is null) - -9. **Check for existing tokens on mount:** - - ```typescript - useEffect(() => { - if (inspectorClient && !oauthState?.oauthTokens) { - inspectorClient.getOAuthTokens().then((tokens) => { - if (tokens) { - // State will be updated via getOAuthState() in sync effect - setOauthState(inspectorClient.getOAuthState()); - } - }); - } - }, [inspectorClient, oauthState]); - ``` - ---- - -### Step 4.6: Update OAuthFlowProgress Component - -**File:** `web/src/components/OAuthFlowProgress.tsx` - -**Current Implementation:** - -- Receives `authState`, `updateAuthState`, and `proceedToNextStep` as props -- Uses custom `DebugInspectorOAuthClientProvider` to fetch client info - -**As-Built Changes:** - -1. **Update component props:** - - ```typescript - interface OAuthFlowProgressProps { - oauthState: AuthGuidedState | undefined; - proceedToNextStep: () => Promise; - ensureInspectorClient: () => InspectorClient | null; - } - ``` - - **Note:** Component receives `oauthState` as prop (from `AuthDebugger`'s local state) rather than accessing `inspectorClient` directly. This keeps the component simpler and follows React best practices. - -2. **Remove custom provider usage:** - - ```typescript - // Remove - import { DebugInspectorOAuthClientProvider } from "../lib/auth"; - - // Add - import type { AuthGuidedState } from "@modelcontextprotocol/inspector-core/auth/types.js"; - import type { InspectorClient } from "@modelcontextprotocol/inspector-core/mcp/index.js"; - ``` - -3. **Manual Authorization Code Entry:** - - Added input field at `authorization_code` step for manual code entry - - Uses `localAuthCode` state synchronized with `oauthState.authorizationCode` - - `onBlur` and `Enter` key: calls `client.setGuidedAuthorizationCode(code, false)` - - "Continue" button checks if code needs to be set before proceeding - -4. **Manual URL Opening Button:** - - External link icon button at `authorization_redirect` step - - Opens authorization URL in new tab when clicked - - No auto-opening - user must manually click button - -5. **Update step rendering to use `oauthState`:** - - ```typescript - // Replace `authState` references with `oauthState` - const currentStepIdx = steps.findIndex((s) => s === oauthState?.oauthStep); - - const getStepProps = (stepName: OAuthStep) => ({ - isComplete: - currentStepIdx > steps.indexOf(stepName) || - currentStepIdx === steps.length - 1, - isCurrent: oauthState?.oauthStep === stepName, - error: oauthState?.oauthStep === stepName ? oauthState.latestError : null, - }); - ``` - -6. **Update `AuthDebugger` to pass props:** - - ```typescript - // In AuthDebugger component: - - ``` - -**Key Implementation Details:** - -- Manual code entry matches TUI UX pattern -- No auto-opening of authorization URL (removed from `proceedToNextStep`) -- Manual button available for users who want to open URL -- Code synchronization between input field and `InspectorClient` state - ---- - -### Step 4.7: Remove Custom OAuth Code - -**Files to Delete:** - -- `web/src/lib/auth.ts` - Custom `InspectorOAuthClientProvider` and `DebugInspectorOAuthClientProvider` -- `web/src/lib/oauth-state-machine.ts` - Custom `OAuthStateMachine` (duplicates shared implementation) -- `web/src/lib/auth-types.ts` - Custom `AuthGuidedState` type (use shared type instead) - -**Files to Update:** - -- `web/src/components/AuthDebugger.tsx` - Remove imports of deleted files -- `web/src/components/OAuthFlowProgress.tsx` - Remove imports of deleted files -- `web/src/App.tsx` - Remove imports of deleted files, remove `authState` state management - -**Note:** `web/src/utils/oauthUtils.ts` (OAuth URL parsing utilities) should be kept as it's still needed. - ---- - -### Step 4.8: Update Environment Factory (Already Complete) - -**File:** `web/src/lib/adapters/environmentFactory.ts` - -**Status:** āœ… Already configured correctly - -The environment factory already uses `BrowserOAuthStorage` and `BrowserNavigation` from shared: - -```typescript -import { - BrowserOAuthStorage, - BrowserNavigation, -} from "@modelcontextprotocol/inspector-core/auth/browser/index.js"; - -export function createWebEnvironment( - authToken: string | undefined, - redirectUrlProvider: RedirectUrlProvider, -): InspectorClientEnvironment { - // ... - oauth: { - storage: new BrowserOAuthStorage(), - navigation: new BrowserNavigation(), - redirectUrlProvider, - }, -} -``` - -**No changes needed.** - ---- - -### Step 4.9: Update Tests - -**Files to Update:** - -- `web/src/components/__tests__/AuthDebugger.test.tsx` - Mock `InspectorClient` OAuth methods instead of custom providers -- `web/src/components/__tests__/OAuthCallback.test.tsx` (if exists) - Update to use `InspectorClient` -- `web/src/__tests__/App.config.test.tsx` - Verify OAuth config is passed to `InspectorClient` - -**Test Strategy:** - -1. Mock `InspectorClient` methods: `authenticate()`, `completeOAuthFlow()`, `beginGuidedAuth()`, `proceedOAuthStep()`, `getOAuthState()`, `getOAuthTokens()` -2. Test that OAuth callbacks call `inspectorClient.completeOAuthFlow()` with correct code -3. Test that guided flow calls `beginGuidedAuth()` and `proceedOAuthStep()` correctly -4. Test that OAuth state updates via `oauthStepChange` events - ---- - -### Implementation Order (As-Built) - -1. **Step 4.1:** Follow TUI pattern - components manage OAuth state directly (no hook changes needed) āœ… -2. **Step 4.2:** Update `OAuthCallback` component - single redirect URL with state parameter āœ… -3. **Step 4.3:** Remove `OAuthDebugCallback` component - consolidated into single callback āœ… -4. **Step 4.4:** Update `App.tsx` routes - single callback route, `ensureInspectorClient` pattern āœ… -5. **Step 4.5:** Refactor `AuthDebugger` component - OAuth state management, lazy client creation āœ… -6. **Step 4.6:** Update `OAuthFlowProgress` component - manual code entry, manual URL button āœ… -7. **Step 4.7:** Remove custom OAuth code (cleanup) āœ… -8. **Step 4.9:** Update tests - all tests rewritten and passing āœ… - -**Dependencies:** - -- Step 4.1 is a pattern decision (no code changes) - components will manage OAuth state directly -- Steps 4.2-4.4 can be done independently (they don't need OAuth state) -- Step 4.5 implements the OAuth state management pattern from Step 4.1 -- Step 4.6 depends on Step 4.5 (receives `oauthState` as prop) -- Step 4.7 should be done last (after all components updated) -- Step 4.9 should be done alongside component updates - ---- - -### Migration Notes - -**Breaking Changes:** - -- `OAuthCallback` now requires `inspectorClient` and `ensureInspectorClient` props -- `OAuthDebugCallback` removed (consolidated into `OAuthCallback`) -- `AuthDebugger` no longer uses `authState` prop (reads from `InspectorClient`) -- `OAuthFlowProgress` no longer uses `authState` prop -- Single redirect URL (`/oauth/callback`) for both normal and guided modes - -**Backward Compatibility:** - -- OAuth redirect URL changed: single `/oauth/callback` endpoint (removed `/oauth/callback/debug`) -- OAuth storage location remains the same (sessionStorage via `BrowserOAuthStorage`) -- OAuth flow behavior remains the same (normal vs guided), but mode determined by state parameter -- Guided mode callback shows code for manual copying (matches TUI UX) - -**Testing Checklist:** - -- [x] Quick OAuth flow (normal mode) works end-to-end - āœ… Tests written and passing -- [x] Guided OAuth flow works step-by-step - āœ… Tests written and passing -- [x] OAuth callback handles success case - āœ… Updated to use InspectorClient -- [x] OAuth callback handles error cases - āœ… Updated to use InspectorClient -- [x] OAuth callback handles guided mode (new tab scenario) - āœ… Shows code for manual copying -- [x] OAuth callback handles guided mode (same tab scenario) - āœ… Sets code without auto-completing -- [x] Single redirect URL approach works - āœ… State parameter distinguishes modes -- [x] Manual code entry in OAuthFlowProgress - āœ… Tests written and passing -- [x] OAuth tokens persist across page reloads - āœ… Handled by BrowserOAuthStorage -- [x] OAuth state updates correctly via events - āœ… Tests written and passing -- [x] Clear OAuth functionality works - āœ… Tests written and passing -- [x] No auto-opening of authorization URL in guided flow - āœ… Test updated -- [ ] OAuth works with both SSE and streamable-http transports - āøļø Needs integration testing - -**Key Implementation Details Discovered:** - -1. **Single Redirect URL with State Parameter:** - - Uses `/oauth/callback` for both normal and guided modes - - Mode encoded in OAuth `state` parameter: `"guided:{random}"` or `"normal:{random}"` - - Matches TUI implementation pattern - - Eliminates need for separate debug endpoint - -2. **Guided Mode Callback Handling:** - - **New Tab Scenario:** Shows authorization code for manual copying, no redirect - - **Same Tab Scenario:** Sets code via `setGuidedAuthorizationCode(code, false)` without auto-completing - - Message: "Please copy this authorization code and return to the Guided Auth flow:" - - Code stored in `sessionStorage` for potential auto-fill (future enhancement) - -3. **InspectorClient Creation Strategy:** - - Lazy creation via `ensureInspectorClient()` helper - - Validates API token before creating client - - Shows toast error if API token missing - - Client created on-demand when needed (connect or auth operations) - - Prevents creating client without API token (would fail API calls) - -4. **Manual Code Entry:** - - Input field added to `OAuthFlowProgress` at `authorization_code` step - - Synchronized with `InspectorClient` state via `setGuidedAuthorizationCode()` - - Matches TUI UX pattern for guided flow - -5. **No Auto-Opening of Authorization URL:** - - Removed auto-opening logic from `proceedToNextStep()` - - Manual button (external link icon) available at `authorization_redirect` step - - Users manually copy code in guided flow (no auto-redirect) - -6. **Remote Logging:** - - Uses `InspectorClient.logger` for persistent logging through redirects - - Logs written to server via `/api/mcp/log` endpoint - - Helps debug OAuth flow across page redirects - -7. **Callback Routing:** - - Handles both `/oauth/callback` and `/` paths (some auth servers redirect incorrectly) - - Checks for OAuth callback parameters in URL search string - - Early return for guided mode without client to avoid unnecessary API calls - -**Functionality Comparison:** - -**Old Implementation Features:** - -- āœ… Explicit error message when serverUrl missing - **Now:** InspectorClient throws error if OAuth not configured (handled via toast) -- āœ… Manual sessionStorage management - **Now:** Handled by BrowserOAuthStorage in InspectorClient -- āœ… Explicit scope discovery (`discoverScopes()`) - **Now:** Handled internally by InspectorClient -- āœ… Manual client registration (static vs DCR) - **Now:** Handled internally by InspectorClient -- āœ… Protected resource metadata discovery - **Now:** Handled internally by InspectorClient -- āœ… State persistence before redirect - **Now:** Handled internally by InspectorClient via BrowserOAuthStorage -- āœ… Step-by-step guided flow with manual state updates - **Now:** Handled by InspectorClient's guided auth flow - -**Potential Gaps/Notes:** - -- āš ļø **Server URL validation**: Old code showed explicit error "Please enter a server URL in the sidebar before authenticating". New code relies on InspectorClient being properly configured with OAuth config. If InspectorClient is null or OAuth not configured, error is shown via toast (different UX but same functionality). -- āš ļø **Manual authorization code entry in guided flow**: Old `OAuthFlowProgress` component had an input field for manual authorization code entry during guided flow. New implementation shows the code if received but doesn't have an input field. However: - - Normal flow: Authorization code handled automatically via callback (`completeOAuthFlow()`) - - Debug flow: `OAuthDebugCallback` component still shows code for manual copying - - If manual entry needed: Can call `inspectorClient.completeOAuthFlow(code)` directly, but UI doesn't expose this - - **Status**: Minor UX gap - functionality exists but not exposed in UI. Could add input field to `OAuthFlowProgress` if needed. -- āœ… **All OAuth features preserved**: Static client, DCR, CIMD, scope discovery, resource metadata, token refresh - all handled by InspectorClient internally. - ---- - -## Phase 5: Remove Express Server Dependency āœ… COMPLETE - -**Status:** āœ… Complete - Express proxy server has been completely removed. No Express server code exists in `web/bin/start.js`. The web app uses only Hono server (via Vite middleware in dev, or `bin/server.js` in prod) for all API endpoints and static file serving. - -**Verification:** - -- āœ… No Express imports or references in `web/bin/start.js` -- āœ… No Express imports or references in `web/src/` (except one test mock value) -- āœ… No Express dependencies in `web/package.json` -- āœ… No proxy server spawning code in start scripts -- āœ… `/config` endpoint replaced with HTML template injection - -**Remaining Legacy References:** - -- `web/src/components/__tests__/Sidebar.test.tsx:64` - Test mock has `connectionType: "proxy"` - This is an unused prop in the test mock (not in actual `SidebarProps` interface). Harmless but should be removed for cleanliness. - -### Step 5.1: Update Start Scripts āœ… COMPLETE - -**File:** `web/bin/start.js` - -**Status:** āœ… Complete - -**As-Built:** - -- `startDevClient()` starts only Vite (Hono middleware handles `/api/*` routes) -- `startProdClient()` starts only Hono server (`bin/server.js`) which serves static files + `/api/*` endpoints -- No Express server spawning code exists -- No `startDevServer()` or `startProdServer()` functions exist - ---- - -### Step 5.2: Remove Proxy Configuration āœ… COMPLETE - -**File:** `web/src/utils/configUtils.ts` - -**Status:** āœ… Complete - -**As-Built:** - -- No `getMCPProxyAddress()` function exists -- No proxy auth token handling (uses `MCP_INSPECTOR_API_TOKEN` for remote API auth) -- No proxy server references in code - ---- - -### Step 5.3: Replace `/config` Endpoint with HTML Template Injection āœ… COMPLETE - -**Files:** `web/bin/server.js`, `web/vite.config.ts`, `web/bin/start.js`, `web/src/App.tsx` - -**Status:** āœ… Complete - -**Approach:** Instead of fetching initial configuration from the Express proxy's `/config` endpoint, we inject configuration values directly into the HTML template served by the Hono server (prod) and Vite dev server (dev). This eliminates the dependency on the Express proxy for initial configuration. - -**Implementation:** - -1. **Start Script (`web/bin/start.js`):** - - Passes config values (command, args, transport, serverUrl, envVars) via environment variables (`MCP_INITIAL_COMMAND`, `MCP_INITIAL_ARGS`, `MCP_INITIAL_TRANSPORT`, `MCP_INITIAL_SERVER_URL`, `MCP_ENV_VARS`) to both Vite (dev) and Hono server (prod) - -2. **Hono Server (`web/bin/server.js`):** - - Intercepts requests to `/` (root) - - Reads `index.html` from dist folder - - Builds `initialConfig` object from env vars (includes `defaultEnvironment` from `getDefaultEnvironment()` + `MCP_ENV_VARS`) - - Injects `` before `` - - Returns modified HTML - -3. **Vite Config (`web/vite.config.ts`):** - - Adds middleware to intercept `/` and `/index.html` requests - - Same injection logic as Hono server (reads from `index.html` source, injects config, returns modified HTML) - -4. **App.tsx (`web/src/App.tsx`):** - - Removed `/config` endpoint fetch - - Reads from `window.__INITIAL_CONFIG__` in a `useEffect` (runs once on mount) - - Applies config values to state (env, command, args, transport, serverUrl) - -**Benefits:** - -- No network request needed for initial config (available immediately) -- Removes dependency on Express proxy for config -- Clean URLs (no query params required for config) -- Works in both dev and prod modes -- Values available synchronously before React renders - -**As-Built:** - -- Config injection happens in both dev (Vite middleware) and prod (Hono server route) -- Uses `getDefaultEnvironment()` from SDK to get default env vars (PATH, HOME, USER, etc.) -- Merges with `MCP_ENV_VARS` if provided -- Config object structure matches what `/config` endpoint returned: `{ defaultCommand?, defaultArgs?, defaultTransport?, defaultServerUrl?, defaultEnvironment }` - ---- - -## Phase 6: Testing and Validation āøļø IN PROGRESS - -### Step 6.1: Functional Testing - -Test each feature to ensure parity with `client/`: - -- [x] Connection management (connect/disconnect) - āœ… Basic functionality working -- [x] Transport types (stdio, SSE, streamable-http) - āœ… All transport types supported -- [x] Tools (list, call, test) - āœ… Working via InspectorClient -- [x] Resources (list, read, subscribe) - āœ… Working via InspectorClient -- [x] Prompts (list, get) - āœ… Working via InspectorClient -- [x] OAuth flows (static client, DCR, CIMD) - āœ… Integrated via InspectorClient (Phase 4 complete) -- [x] Custom headers - āœ… Supported in config adapter -- [x] Request history - āœ… Using MCP protocol messages -- [x] Stderr logging - āœ… ConsoleTab displays stderr logs -- [x] Notifications - āœ… Extracted from message stream -- [x] Elicitation requests - āœ… Event listeners working -- [x] Sampling requests - āœ… Event listeners working -- [x] Roots management - āœ… getRoots/setRoots working -- [ ] Progress notifications - āøļø Needs validation - -**Recent Bug Fixes:** - -- āœ… Fixed infinite loop in `useInspectorClient` hook (messages/stderrLogs/fetchRequests) - root cause: `InspectorClient.getMessages()` returns new array references. Fixed by comparing serialized content before updating state. -- āœ… Fixed infinite loop in `App.tsx` notifications extraction - fixed by using `useMemo` + `useRef` with content comparison -- āœ… Removed debug `console.log` statements from `App.tsx` -- āœ… Added console output capture in tests (schemaUtils, auth tests) to validate expected warnings/debug messages - ---- - -### Step 6.2: Integration Testing - -- [ ] Dev mode: Vite + Hono middleware works -- [ ] Prod mode: Hono server serves static files and API -- [ ] Same-origin requests (no CORS issues) -- [ ] Auth token handling -- [ ] Storage persistence -- [ ] Error handling - ---- - -## Phase 7: Cleanup - -### Step 7.1: Remove Unused Code - -- [x] Delete `useConnection.ts` hook - āœ… Already removed (no files found) -- [x] Remove Express server references - āœ… Express proxy completely removed (no Express code exists) -- [x] Remove proxy-related utilities - āœ… No proxy utilities found in codebase -- [x] Clean up unused imports - āœ… Basic cleanup done (console.log removed) -- [x] Remove unused test prop - āœ… Removed `connectionType` and `setConnectionType` from `Sidebar.test.tsx` mock - ---- - -### Step 7.2: Update Documentation - -- [ ] Update README with new architecture -- [ ] Document Hono integration -- [ ] Update development setup instructions - ---- - -## Implementation Order - -**Recommended order:** - -1. **Phase 1** (Hono Integration) - Foundation for everything else -2. **Phase 2** (Adapters) - Needed before Phase 3 -3. **Phase 3** (InspectorClient Integration) - Core functionality -4. **Phase 4** (OAuth) - Can be done in parallel with Phase 3 -5. **Phase 5** (Remove Express) - After everything works -6. **Phase 6** (Testing) - Throughout, but comprehensive at end (functional and integration testing only) -7. **Phase 7** (Cleanup) - Final step - ---- - -## Key Differences from Current Client - -| Aspect | Current Client | New Web App | -| -------------- | ----------------------------- | ---------------------------------------------- | -| **Transport** | Direct SDK transports + proxy | Remote transport via Hono API | -| **Server** | Separate Express server | Hono middleware in Vite | -| **OAuth** | Custom state machine | InspectorClient OAuth methods | -| **State** | Custom formats | InspectorClient formats (MessageEntry[], etc.) | -| **Connection** | useConnection hook | InspectorClient + useInspectorClient | -| **Fetch** | Direct fetch | createRemoteFetch (for OAuth) | -| **Logging** | Console only | createRemoteLogger | - ---- - -## Success Criteria - -The port is complete when: - -1. āœ… All features from `client/` work identically in `web/` -2. āœ… No separate Express server required -3. āœ… Same-origin requests (no CORS) -4. āœ… OAuth flows work (static, DCR, CIMD) -5. āœ… All transport types work (stdio, SSE, streamable-http) -6. āœ… Request history, stderr logs, notifications all work -7. āœ… Code is cleaner and more maintainable - ---- - -## Notes - -- Keep `client/` unchanged during port (it's the reference implementation) -- Test incrementally - don't try to port everything at once -- Use feature flags if needed to test new code alongside old code -- The web app can be deleted from the PR after POC is complete (if not merging) - ---- - -## Issues - -Future: Extend useInspectorClient to expose OAuth state (for guided flow in web and TUI) - -node cli/build/cli.js --web --config mcp.json --server hosted-everything diff --git a/package-lock.json b/package-lock.json index 269c77f34..e9d337335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,11 @@ "version": "0.20.0", "license": "SEE LICENSE IN LICENSE", "workspaces": [ - "web", - "cli", - "tui", + "clients/web", + "clients/cli", + "clients/tui", "core", + "clients/launcher", "test-servers" ], "dependencies": { @@ -21,22 +22,17 @@ "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", "hono": "^4.11.7", - "node-fetch": "^3.3.2", "open": "^10.2.0", - "shell-quote": "^1.8.3", - "spawn-rx": "^5.1.2", - "ts-node": "^10.9.2", "zod": "^3.25.76" }, "bin": { - "mcp-inspector": "cli/build/cli.js" + "mcp-inspector": "clients/launcher/build/index.js" }, "devDependencies": { "@eslint/js": "^9.11.1", "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", "@types/node": "^22.17.0", - "@types/shell-quote": "^1.7.5", "eslint": "^9.11.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.12", @@ -55,27 +51,168 @@ "node": ">=22.7.5" } }, - "cli": { + "clients/cli": { "name": "@modelcontextprotocol/inspector-cli", "version": "0.20.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector-core": "*", "@modelcontextprotocol/sdk": "^1.25.2", - "commander": "^13.1.0", - "express": "^5.1.0", - "spawn-rx": "^5.1.2" + "commander": "^13.1.0" }, "bin": { - "mcp-inspector-cli": "build/cli.js" + "mcp-inspector-cli": "build/index.js" }, "devDependencies": { "@modelcontextprotocol/inspector-test-server": "*", - "@types/express": "^5.0.0", "tsx": "^4.7.0", "vitest": "^4.0.17" } }, + "clients/launcher": { + "name": "@modelcontextprotocol/inspector-launcher", + "version": "0.20.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/inspector-cli": "*", + "@modelcontextprotocol/inspector-tui": "*", + "@modelcontextprotocol/inspector-web": "*", + "commander": "^13.1.0" + }, + "bin": { + "mcp-inspector": "build/index.js" + }, + "devDependencies": { + "@types/node": "^22.17.0", + "typescript": "^5.4.2" + } + }, + "clients/tui": { + "name": "@modelcontextprotocol/inspector-tui", + "version": "0.20.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/inspector-core": "*", + "@modelcontextprotocol/sdk": "^1.25.2", + "commander": "^13.1.0", + "ink": "^5.2.1", + "ink-form": "^2.0.1", + "ink-scroll-view": "^0.3.6", + "open": "^10.2.0", + "pino": "^9.6.0", + "react": "^18.3.1" + }, + "bin": { + "mcp-inspector-tui": "build/index.js" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "@types/react": "^18.3.23", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.17" + } + }, + "clients/tui/node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "clients/web": { + "name": "@modelcontextprotocol/inspector-web", + "version": "0.20.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.0", + "@mcp-ui/client": "^6.0.0", + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/inspector-core": "*", + "@modelcontextprotocol/sdk": "^1.25.2", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.3", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.3", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "^1.0.4", + "commander": "^13.1.0", + "lucide-react": "^0.523.0", + "open": "^10.1.0", + "pino": "^9.6.0", + "pkce-challenge": "^4.1.0", + "prismjs": "^1.30.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-simple-code-editor": "^0.14.1", + "tailwind-merge": "^2.5.3", + "vite": "^7.1.11", + "zod": "^3.25.76" + }, + "bin": { + "mcp-inspector-web": "build/index.js" + }, + "devDependencies": { + "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@types/node": "^22.17.0", + "@types/prismjs": "^1.26.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.20", + "co": "^4.6.0", + "eslint": "^9.11.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "jsdom": "^25.0.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.5.3", + "typescript-eslint": "^8.38.0", + "vitest": "^4.0.17" + } + }, + "clients/web/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "clients/web/node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "core": { "name": "@modelcontextprotocol/inspector-core", "version": "0.20.0", @@ -463,28 +600,6 @@ "node": ">=6.9.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -607,7 +722,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -624,7 +738,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -641,7 +754,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,7 +770,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -675,7 +786,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -692,7 +802,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -709,7 +818,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -726,7 +834,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -743,7 +850,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -760,7 +866,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -777,7 +882,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -794,7 +898,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -811,7 +914,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -828,7 +930,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -845,7 +946,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -862,7 +962,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -879,7 +978,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -896,7 +994,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -913,7 +1010,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -930,7 +1026,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -947,7 +1042,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -964,7 +1058,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -981,7 +1074,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -998,7 +1090,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1015,7 +1106,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1032,7 +1122,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1085,15 +1174,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1126,9 +1215,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", - "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -1139,7 +1228,7 @@ "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.3", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1179,17 +1268,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -1224,31 +1306,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.5" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1256,15 +1338,15 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1638,6 +1720,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1647,6 +1730,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1723,33 +1807,13 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.1.2.tgz", - "integrity": "sha512-Gx4TEo3/F8yq1Ix6LdgLwMrKqfZqD7++eakZdbMUewrYtHeeJn3nKpeNhgEfO7nYRwonqWYomOAszWZWJS0IbA==", - "hasInstallScript": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.2.0.tgz", + "integrity": "sha512-ijUQJX/FmNq8PWgOLzph/BAfy84sUZxoIRuHzr+F37wYtWjhdl8pliBJybapYolppY+XJ8oqjFZmTOuMqxwbWQ==", "license": "MIT", "workspaces": [ "examples/*" ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" - }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -1766,23 +1830,27 @@ } }, "node_modules/@modelcontextprotocol/inspector-cli": { - "resolved": "cli", + "resolved": "clients/cli", "link": true }, "node_modules/@modelcontextprotocol/inspector-core": { "resolved": "core", "link": true }, + "node_modules/@modelcontextprotocol/inspector-launcher": { + "resolved": "clients/launcher", + "link": true + }, "node_modules/@modelcontextprotocol/inspector-test-server": { "resolved": "test-servers", "link": true }, "node_modules/@modelcontextprotocol/inspector-tui": { - "resolved": "tui", + "resolved": "clients/tui", "link": true }, "node_modules/@modelcontextprotocol/inspector-web": { - "resolved": "web", + "resolved": "clients/web", "link": true }, "node_modules/@modelcontextprotocol/sdk": { @@ -2029,9 +2097,9 @@ } }, "node_modules/@preact/signals-core": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", - "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz", + "integrity": "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -3102,7 +3170,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3116,7 +3183,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3156,7 +3222,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3170,7 +3235,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3184,7 +3248,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3198,7 +3261,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3225,7 +3287,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3239,7 +3300,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3253,7 +3313,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3267,7 +3326,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3281,7 +3339,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3295,7 +3352,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3309,7 +3365,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3323,7 +3378,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3350,7 +3404,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3364,7 +3417,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3378,7 +3430,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3405,7 +3456,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3419,7 +3469,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3552,30 +3601,6 @@ } } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "license": "MIT" - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3672,7 +3697,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -3821,14 +3845,22 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -3843,9 +3875,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "dev": true, "license": "MIT" }, @@ -3897,13 +3929,6 @@ "@types/node": "*" } }, - "node_modules/@types/shell-quote": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", - "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3937,17 +3962,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -3960,7 +3985,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -3976,16 +4001,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "engines": { @@ -4001,14 +4026,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "engines": { @@ -4023,14 +4048,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4041,9 +4066,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", "dev": true, "license": "MIT", "engines": { @@ -4058,15 +4083,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -4083,9 +4108,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "dev": true, "license": "MIT", "engines": { @@ -4097,16 +4122,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4177,16 +4202,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4201,13 +4226,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4380,6 +4405,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4398,18 +4424,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4453,9 +4467,15 @@ } } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "node_modules/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "license": "MIT", "dependencies": { @@ -4832,9 +4852,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -5298,12 +5318,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5365,15 +5379,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -5501,15 +5506,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5556,9 +5552,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -5656,9 +5652,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", "license": "MIT", "workspaces": [ "docs", @@ -5669,7 +5665,6 @@ "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5736,25 +5731,25 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -5773,7 +5768,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5865,13 +5860,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -6054,12 +6042,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -6146,29 +6134,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/figures": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", @@ -6276,9 +6241,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -6322,18 +6287,6 @@ "node": ">= 0.6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6370,7 +6323,6 @@ "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, @@ -6471,7 +6423,7 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6632,9 +6584,9 @@ } }, "node_modules/hono": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", - "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -6938,9 +6890,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -7594,16 +7546,16 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" } }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7690,9 +7642,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -7766,16 +7718,15 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.0.tgz", - "integrity": "sha512-YVHHy/p6U4/No9Af+35JLh3umJ9dPQnGTvNCbfO/T5fC60us0jFnc+vw33cqveI+kqxIFJQakcMVTO2KM+653A==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.2.tgz", + "integrity": "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg==", "dev": true, "license": "MIT", "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", "string-argv": "^0.3.2", "tinyexec": "^1.0.2", "yaml": "^2.8.2" @@ -7818,15 +7769,28 @@ "node": ">=20.0.0" } }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -7835,6 +7799,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/listr2/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/listr2/node_modules/string-width": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", @@ -8016,12 +8013,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "license": "ISC" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8174,24 +8165,10 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -8222,48 +8199,10 @@ "node": ">= 0.6" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -8564,7 +8503,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -8679,10 +8617,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -9257,7 +9194,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -9321,7 +9258,6 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -9712,22 +9648,11 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/spawn-rx": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/spawn-rx/-/spawn-rx-5.1.2.tgz", - "integrity": "sha512-/y7tJKALVZ1lPzeZZB9jYnmtrL7d0N2zkorii5a7r7dhHkWIuLTzZpZzMJLK1dmYRgX/NCc4iarTO3F7BS2c/A==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.7", - "rxjs": "^7.8.1" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -10055,7 +9980,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -10072,7 +9996,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10090,7 +10013,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10206,55 +10128,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -10265,7 +10138,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -10285,7 +10158,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10351,6 +10223,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10361,16 +10234,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10385,9 +10258,10 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -10489,12 +10363,6 @@ "dev": true, "license": "MIT" }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "license": "MIT" - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -10508,7 +10376,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -10583,7 +10450,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10601,7 +10467,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -10616,7 +10481,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10729,15 +10593,6 @@ "node": ">=18" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -11049,15 +10904,6 @@ "node": ">=8" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11141,144 +10987,6 @@ "typescript": "^5.4.2", "vitest": "^4.0.17" } - }, - "tui": { - "name": "@modelcontextprotocol/inspector-tui", - "version": "0.20.0", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/inspector-core": "*", - "@modelcontextprotocol/sdk": "^1.25.2", - "commander": "^13.1.0", - "ink": "^5.2.1", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", - "open": "^10.2.0", - "pino": "^9.6.0", - "react": "^18.3.1" - }, - "bin": { - "mcp-inspector-tui": "build/tui.js" - }, - "devDependencies": { - "@types/node": "^25.0.3", - "@types/react": "^18.3.23", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vitest": "^4.0.17" - } - }, - "tui/node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "tui/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "web": { - "name": "@modelcontextprotocol/inspector-web", - "version": "0.20.0", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.0", - "@mcp-ui/client": "^6.0.0", - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/inspector-core": "*", - "@modelcontextprotocol/sdk": "^1.25.2", - "@radix-ui/react-checkbox": "^1.1.4", - "@radix-ui/react-dialog": "^1.1.3", - "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.3", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-tooltip": "^1.1.8", - "ajv": "^6.12.6", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "cmdk": "^1.0.4", - "lucide-react": "^0.523.0", - "open": "^10.1.0", - "pino": "^9.6.0", - "pkce-challenge": "^4.1.0", - "prismjs": "^1.30.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-simple-code-editor": "^0.14.1", - "tailwind-merge": "^2.5.3", - "zod": "^3.25.76" - }, - "bin": { - "mcp-inspector-web": "bin/start.js" - }, - "devDependencies": { - "@eslint/js": "^9.11.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@types/node": "^22.17.0", - "@types/prismjs": "^1.26.5", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^5.0.4", - "autoprefixer": "^10.4.20", - "co": "^4.6.0", - "eslint": "^9.11.1", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.9.0", - "jsdom": "^25.0.1", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.13", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.5.3", - "typescript-eslint": "^8.38.0", - "vite": "^7.1.11", - "vitest": "^4.0.17" - } - }, - "web/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "web/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "web/node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } } } } diff --git a/package.json b/package.json index af881f6c4..4f86cb307 100644 --- a/package.json +++ b/package.json @@ -8,44 +8,48 @@ "bugs": "https://github.com/modelcontextprotocol/inspector/issues", "type": "module", "bin": { - "mcp-inspector": "cli/build/cli.js" + "mcp-inspector": "clients/launcher/build/index.js" }, "files": [ - "web/bin", - "web/dist", - "cli/build", - "tui/build" + "clients/web/build", + "clients/web/dist", + "clients/cli/build", + "clients/tui/build", + "clients/launcher/build" ], "workspaces": [ - "web", - "cli", - "tui", + "clients/web", + "clients/cli", + "clients/tui", "core", + "clients/launcher", "test-servers" ], "scripts": { - "build": "npm run build-core && npm run build-test && npm run build-web && npm run build-cli && npm run build-tui", + "build": "npm run build-core && npm run build-test && npm run build-web && npm run build-cli && npm run build-tui && npm run build-launcher", "build-core": "cd core && npm run build", - "build-web": "cd web && npm run build", - "build-cli": "cd cli && npm run build", - "build-tui": "cd tui && npm run build", + "build-web": "cd clients/web && npm run build", + "build-cli": "cd clients/cli && npm run build", + "build-tui": "cd clients/tui && npm run build", + "build-launcher": "cd clients/launcher && npm run build", "build-test": "cd test-servers && npm run build", "server-composable": "node test-servers/build/server-composable.js", - "clean": "rimraf ./node_modules ./web/node_modules ./core/node_modules ./cli/node_modules ./tui/node_modules ./test-servers/node_modules ./build ./web/dist ./cli/build ./tui/build ./test-servers/build ./package-lock.json && npm install", - "dev": "node web/bin/start.js --dev", - "dev:windows": "node web/bin/start.js --dev", + "clean": "rimraf ./node_modules ./clients/web/node_modules ./core/node_modules ./clients/cli/node_modules ./clients/tui/node_modules ./clients/launcher/node_modules ./test-servers/node_modules ./build ./clients/web/dist ./clients/cli/build ./clients/tui/build ./clients/launcher/build ./test-servers/build ./package-lock.json && npm install", + "dev": "node clients/web/build/index.js --dev", + "dev:windows": "node clients/web/build/index.js --dev", + "inspector": "node clients/launcher/build/index.js", "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 web/bin/start.js", - "web": "node cli/build/cli.js --web", - "web:dev": "node cli/build/cli.js --web --dev", + "start": "node clients/web/build/index.js", + "web": "node clients/launcher/build/index.js --web", + "web:dev": "node clients/launcher/build/index.js --web --dev", "test": "vitest run", "test:repeat": "node scripts/test-repeat.js", - "test-cli": "cd cli && npm run test", + "test-cli": "cd clients/cli && npm run test", "test-core": "cd core && npm run test", - "test-web": "cd web && npm run test", - "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=web", + "test-web": "cd clients/web && npm run test", + "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=clients/web", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", @@ -60,11 +64,7 @@ "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", "hono": "^4.11.7", - "node-fetch": "^3.3.2", "open": "^10.2.0", - "shell-quote": "^1.8.3", - "spawn-rx": "^5.1.2", - "ts-node": "^10.9.2", "zod": "^3.25.76" }, "devDependencies": { @@ -77,7 +77,6 @@ "@playwright/test": "^1.54.1", "@types/jest": "^29.5.14", "@types/node": "^22.17.0", - "@types/shell-quote": "^1.7.5", "husky": "^9.1.7", "jest-fixed-jsdom": "^0.0.9", "lint-staged": "^16.1.5", diff --git a/scripts/check-version-consistency.js b/scripts/check-version-consistency.js index 99ef56f7b..8b8ba477b 100755 --- a/scripts/check-version-consistency.js +++ b/scripts/check-version-consistency.js @@ -18,10 +18,11 @@ console.log("šŸ” Checking version consistency across packages...\n"); // List of package.json files to check const packagePaths = [ "package.json", - "web/package.json", + "clients/web/package.json", "core/package.json", - "cli/package.json", - "tui/package.json", + "clients/cli/package.json", + "clients/tui/package.json", + "clients/launcher/package.json", "test-servers/package.json", ]; diff --git a/scripts/update-version.js b/scripts/update-version.js index 136c0c8c0..b64e07c47 100755 --- a/scripts/update-version.js +++ b/scripts/update-version.js @@ -37,10 +37,11 @@ console.log(`šŸ”„ Updating all packages to version ${newVersion}...`); // List of package.json files to update const packagePaths = [ "package.json", - "web/package.json", + "clients/web/package.json", "core/package.json", - "cli/package.json", - "tui/package.json", + "clients/cli/package.json", + "clients/tui/package.json", + "clients/launcher/package.json", "test-servers/package.json", ]; diff --git a/vitest.config.ts b/vitest.config.ts index a2ee9b00b..949793e93 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,11 +7,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { projects: [ - "cli/vitest.config.ts", + "clients/cli/vitest.config.ts", "core/vitest.config.ts", "test-servers/vitest.config.ts", - "tui/vitest.config.ts", - "web/vitest.config.ts", + "clients/tui/vitest.config.ts", + "clients/web/vitest.config.ts", ], }, }); diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 780c92d8b..000000000 --- a/web/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - }, -}); -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from "eslint-plugin-react"; - -export default tseslint.config({ - // Set the react version - settings: { react: { version: "18.3" } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs["jsx-runtime"].rules, - }, -}); -``` diff --git a/web/bin/start.js b/web/bin/start.js deleted file mode 100755 index 49e4082dd..000000000 --- a/web/bin/start.js +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env node - -import open from "open"; -import { resolve, dirname } from "path"; -import { spawnPromise, spawn } from "spawn-rx"; -import { fileURLToPath } from "url"; -import { randomBytes } from "crypto"; -import { - API_SERVER_ENV_VARS, - LEGACY_AUTH_TOKEN_ENV, -} from "@modelcontextprotocol/inspector-core/mcp/remote"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -async function startDevClient(clientOptions) { - const { - CLIENT_PORT, - inspectorApiToken, - dangerouslyOmitAuth, - command, - mcpServerArgs, - transport, - serverUrl, - headers, - envVars, - cwd, - abort, - cancelledRef, - } = clientOptions; - const clientCommand = "npx"; - const host = process.env.HOST || "localhost"; - const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host]; - - // Env for the child process (Vite): API token, initial MCP config, and client config vars - const configEnv = { - ...process.env, - CLIENT_PORT, - ...(dangerouslyOmitAuth - ? {} - : { [API_SERVER_ENV_VARS.AUTH_TOKEN]: inspectorApiToken }), - ...(command ? { MCP_INITIAL_COMMAND: command } : {}), - ...(mcpServerArgs && mcpServerArgs.length > 0 - ? { MCP_INITIAL_ARGS: mcpServerArgs.join(" ") } - : {}), - ...(transport ? { MCP_INITIAL_TRANSPORT: transport } : {}), - ...(serverUrl ? { MCP_INITIAL_SERVER_URL: serverUrl } : {}), - ...(headers && Object.keys(headers).length > 0 - ? { MCP_INITIAL_HEADERS: JSON.stringify(headers) } - : {}), - ...(envVars && Object.keys(envVars).length > 0 - ? { MCP_ENV_VARS: JSON.stringify(envVars) } - : {}), - ...(cwd ? { MCP_INITIAL_CWD: cwd } : {}), - }; - - const client = spawn(clientCommand, clientArgs, { - cwd: resolve(__dirname, ".."), - env: configEnv, - signal: abort.signal, - echoOutput: true, - }); - - // Include Inspector API auth token in URL for client (omit when auth disabled) - const params = new URLSearchParams(); - if (!dangerouslyOmitAuth && inspectorApiToken) { - params.set(API_SERVER_ENV_VARS.AUTH_TOKEN, inspectorApiToken); - } - const url = - params.size > 0 - ? `http://${host}:${CLIENT_PORT}/?${params.toString()}` - : `http://${host}:${CLIENT_PORT}`; - - // Give vite time to start before opening or logging the URL - setTimeout(() => { - console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); - console.log( - ` Static files served by: Vite (dev) / Inspector API server (prod)\n`, - ); - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log("🌐 Opening browser..."); - open(url); - } - }, 3000); - - await new Promise((resolve) => { - client.subscribe({ - complete: resolve, - error: (err) => { - if (!cancelledRef.current || process.env.DEBUG) { - console.error("Client error:", err); - } - resolve(null); - }, - next: () => {}, // We're using echoOutput - }); - }); -} - -async function startProdClient(clientOptions) { - const { - CLIENT_PORT, - inspectorApiToken, - dangerouslyOmitAuth, - abort, - command, - mcpServerArgs, - transport, - serverUrl, - headers, - envVars, - cwd, - } = clientOptions; - const honoServerPath = resolve(__dirname, "../dist/server.js"); - - // Inspector API server (Hono) serves static files + /api/*; it logs and opens browser when listening - try { - await spawnPromise("node", [honoServerPath], { - env: { - ...process.env, - CLIENT_PORT, - ...(dangerouslyOmitAuth - ? {} - : { [API_SERVER_ENV_VARS.AUTH_TOKEN]: inspectorApiToken }), - ...(command ? { MCP_INITIAL_COMMAND: command } : {}), - ...(mcpServerArgs && mcpServerArgs.length > 0 - ? { MCP_INITIAL_ARGS: mcpServerArgs.join(" ") } - : {}), - ...(transport ? { MCP_INITIAL_TRANSPORT: transport } : {}), - ...(serverUrl ? { MCP_INITIAL_SERVER_URL: serverUrl } : {}), - ...(headers && Object.keys(headers).length > 0 - ? { MCP_INITIAL_HEADERS: JSON.stringify(headers) } - : {}), - ...(envVars && Object.keys(envVars).length > 0 - ? { MCP_ENV_VARS: JSON.stringify(envVars) } - : {}), - ...(cwd ? { MCP_INITIAL_CWD: cwd } : {}), - }, - signal: abort.signal, - echoOutput: true, - }); - } catch (err) { - // Child already printed the message (e.g. PORT IS IN USE); exit cleanly without stack - const code = err?.code ?? err?.exitCode; - if (typeof code === "number" && code !== 0) { - process.exit(code); - } - throw err; - } -} - -async function main() { - // Parse command line arguments - const args = process.argv.slice(2); - const envVars = {}; - const mcpServerArgs = []; - let command = null; - let parsingFlags = true; - let isDev = false; - let transport = null; - let serverUrl = null; - let headers = null; - let cwd = null; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (parsingFlags && arg === "--") { - parsingFlags = false; - continue; - } - - if (!parsingFlags) { - if (command === null) command = arg; - else mcpServerArgs.push(arg); - continue; - } - - if (arg === "--dev") { - isDev = true; - continue; - } - - if (arg === "--transport" && i + 1 < args.length) { - transport = args[++i]; - continue; - } - - if (arg === "--server-url" && i + 1 < args.length) { - serverUrl = args[++i]; - continue; - } - - if (arg === "--headers" && i + 1 < args.length) { - try { - headers = JSON.parse(args[++i]); - } catch { - // Ignore invalid JSON - } - continue; - } - - if (arg === "--cwd" && i + 1 < args.length) { - cwd = args[++i]; - continue; - } - - if (arg === "-e" && i + 1 < args.length) { - const envVar = args[++i]; - const equalsIndex = envVar.indexOf("="); - - if (equalsIndex !== -1) { - const key = envVar.substring(0, equalsIndex); - const value = envVar.substring(equalsIndex + 1); - envVars[key] = value; - } else { - envVars[envVar] = ""; - } - } else if (!command) { - command = arg; - } else { - mcpServerArgs.push(arg); - } - } - - // Env fallback when no command/args were passed on the command line (explicit args take precedence) - if (!command && process.env.MCP_INITIAL_COMMAND) { - command = process.env.MCP_INITIAL_COMMAND; - const initialArgs = process.env.MCP_INITIAL_ARGS; - if (initialArgs) - mcpServerArgs.push(...initialArgs.split(" ").filter(Boolean)); - } - if (!serverUrl && process.env.MCP_INITIAL_SERVER_URL) { - serverUrl = process.env.MCP_INITIAL_SERVER_URL; - } - if (!transport && process.env.MCP_INITIAL_TRANSPORT) { - transport = process.env.MCP_INITIAL_TRANSPORT; - } - if (!headers && process.env.MCP_INITIAL_HEADERS) { - try { - headers = JSON.parse(process.env.MCP_INITIAL_HEADERS); - } catch { - // Ignore invalid JSON - } - } - if (!cwd && process.env.MCP_INITIAL_CWD) { - cwd = process.env.MCP_INITIAL_CWD; - } - // For stdio (when command is set), default cwd to process.cwd() if not provided - if (!cwd && command) { - cwd = process.cwd(); - } - - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; - - console.log( - isDev - ? "Starting MCP inspector in development mode..." - : "Starting MCP inspector...", - ); - - const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; - - // Generate Inspector API auth token when auth is enabled (honor legacy MCP_PROXY_AUTH_TOKEN if present) - const inspectorApiToken = dangerouslyOmitAuth - ? "" - : process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || - process.env[LEGACY_AUTH_TOKEN_ENV] || - randomBytes(32).toString("hex"); - - const abort = new AbortController(); - - const cancelledRef = { current: false }; - process.on("SIGINT", () => { - cancelledRef.current = true; - abort.abort(); - }); - - if (isDev) { - // In dev mode: start Vite with Inspector API middleware - try { - const clientOptions = { - CLIENT_PORT, - inspectorApiToken, - dangerouslyOmitAuth, - command, - mcpServerArgs, - transport, - serverUrl, - headers, - envVars, - cwd, - abort, - cancelledRef, - }; - await startDevClient(clientOptions); - } catch (e) { - if (!cancelledRef.current || process.env.DEBUG) throw e; - } - } else { - // In prod mode: start Inspector API server (serves static files + /api/* endpoints) - try { - const clientOptions = { - CLIENT_PORT, - inspectorApiToken, - dangerouslyOmitAuth, - command, - mcpServerArgs, - transport, - serverUrl, - headers, - envVars, - cwd, - abort, - cancelledRef, - }; - await startProdClient(clientOptions); - } catch (e) { - if (!cancelledRef.current || process.env.DEBUG) throw e; - } - } - - return 0; -} - -main() - .then((_) => process.exit(0)) - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/web/src/server.ts b/web/src/server.ts deleted file mode 100644 index 07374d5b4..000000000 --- a/web/src/server.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; -import open from "open"; -import { serve } from "@hono/node-server"; -import { serveStatic } from "@hono/node-server/serve-static"; -import { Hono } from "hono"; -import pino from "pino"; -import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; -import { - API_SERVER_ENV_VARS, - LEGACY_AUTH_TOKEN_ENV, -} from "@modelcontextprotocol/inspector-core/mcp/remote"; -import { - createSandboxController, - resolveSandboxPort, -} from "./sandbox-controller.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -// When run as dist/server.js, __dirname is dist/; index and assets live there -const distPath = __dirname; -const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html"); - -const app = new Hono(); - -const dangerouslyOmitAuth = !!process.env.DANGEROUSLY_OMIT_AUTH; -const authToken = dangerouslyOmitAuth - ? "" - : (process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] ?? - process.env[LEGACY_AUTH_TOKEN_ENV] ?? - randomBytes(32).toString("hex")); - -const port = parseInt(process.env.CLIENT_PORT || "6274", 10); -const host = process.env.HOST || "localhost"; -const baseUrl = `http://${host}:${port}`; - -let sandboxHtml: string; -try { - sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); -} catch (e) { - sandboxHtml = - "Sandbox not loaded: " + - String((e as Error).message) + - ""; -} - -const sandboxController = createSandboxController({ - port: resolveSandboxPort(), - sandboxHtml, - host, -}); -await sandboxController.start(); - -const { app: apiApp } = createRemoteApp({ - authToken: dangerouslyOmitAuth ? undefined : authToken, - dangerouslyOmitAuth, - storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? [baseUrl], - sandboxUrl: sandboxController.getUrl() ?? undefined, - logger: process.env.MCP_LOG_FILE - ? pino( - { level: "info" }, - pino.destination({ - dest: process.env.MCP_LOG_FILE, - append: true, - mkdir: true, - }), - ) - : undefined, -}); - -const SHUTDOWN_TIMEOUT_MS = 10_000; - -async function shutdown(): Promise { - if (shuttingDown) return; - shuttingDown = true; - - const forceExit = setTimeout(() => { - console.error("Shutdown timeout; forcing exit"); - process.exit(1); - }, SHUTDOWN_TIMEOUT_MS); - - try { - await sandboxController.close(); - } catch (err) { - console.error("Sandbox close error:", err); - } - - httpServer.close((err) => { - clearTimeout(forceExit); - if (err) { - console.error("Server close error:", err); - process.exit(1); - } - process.exit(0); - }); -} - -let shuttingDown = false; -process.on("SIGINT", () => { - void shutdown(); -}); -process.on("SIGTERM", () => { - void shutdown(); -}); - -app.use("/api/*", async (c) => { - return apiApp.fetch(c.req.raw); -}); - -app.get("/", async (c) => { - try { - const indexPath = join(distPath, "index.html"); - const html = readFileSync(indexPath, "utf-8"); - return c.html(html); - } catch (error) { - console.error("Error serving index.html:", error); - return c.notFound(); - } -}); - -app.use( - "/*", - serveStatic({ - root: distPath, - rewriteRequestPath: (path) => { - if (!path.includes(".") && !path.startsWith("/api")) { - return "/index.html"; - } - return path; - }, - }), -); - -const httpServer = serve( - { - fetch: app.fetch, - port, - hostname: host, - }, - (info) => { - const baseUrl = `http://${host}:${info.port}`; - const url = - dangerouslyOmitAuth || !authToken - ? baseUrl - : `${baseUrl}?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${authToken}`; - console.log(`\nšŸš€ MCP Inspector Web is up and running at:\n ${url}\n`); - const sandboxUrl = sandboxController.getUrl(); - if (sandboxUrl) { - console.log(` Sandbox (MCP Apps): ${sandboxUrl}\n`); - } - if (dangerouslyOmitAuth) { - console.log(" Auth: disabled (DANGEROUSLY_OMIT_AUTH)\n"); - } else { - console.log(` Auth token: ${authToken}\n`); - } - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { - console.log("🌐 Opening browser..."); - open(url); - } - }, -); - -httpServer.on("error", (err: Error) => { - if (err.message.includes("EADDRINUSE")) { - console.error( - `āŒ MCP Inspector PORT IS IN USE at http://${host}:${port} āŒ `, - ); - process.exit(1); - } else { - throw err; - } -}); diff --git a/web/vite.config.ts b/web/vite.config.ts deleted file mode 100644 index 200d89f88..000000000 --- a/web/vite.config.ts +++ /dev/null @@ -1,260 +0,0 @@ -import react from "@vitejs/plugin-react"; -import path from "path"; -import { readFileSync } from "node:fs"; -import { defineConfig, type Plugin } from "vite"; -import { createRemoteApp } from "@modelcontextprotocol/inspector-core/mcp/remote/node"; -import type { IncomingMessage, ServerResponse } from "node:http"; -import pino from "pino"; -import { - API_SERVER_ENV_VARS, - LEGACY_AUTH_TOKEN_ENV, -} from "@modelcontextprotocol/inspector-core/mcp/remote"; -import { - createSandboxController, - resolveSandboxPort, -} from "./src/sandbox-controller.js"; - -/** - * Vite plugin that adds Hono middleware to handle /api/* routes - * and starts the MCP Apps sandbox server (same process; port from MCP_SANDBOX_PORT / SERVER_PORT / dynamic). - */ -function honoMiddlewarePlugin(options: { - authToken?: string; - dangerouslyOmitAuth?: boolean; -}): Plugin { - return { - name: "hono-api-middleware", - async configureServer(server) { - const sandboxHtmlPath = path.join( - __dirname, - "static", - "sandbox_proxy.html", - ); - let sandboxHtml: string; - try { - sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8"); - } catch { - sandboxHtml = - "Sandbox not loaded"; - } - - const sandboxController = createSandboxController({ - port: resolveSandboxPort(), - sandboxHtml, - host: "localhost", - }); - await sandboxController.start(); - - server.httpServer?.on("close", () => { - sandboxController.close().catch((err) => { - console.error("Sandbox close error:", err); - }); - }); - - // When Vitest (or anything) calls server.close(), close the sandbox first so the process can exit - const originalClose = server.close.bind(server); - server.close = async () => { - await sandboxController.close(); - return originalClose(); - }; - - const { app: honoApp, authToken: resolvedToken } = createRemoteApp({ - authToken: options.dangerouslyOmitAuth ? undefined : options.authToken, - dangerouslyOmitAuth: options.dangerouslyOmitAuth, - storageDir: process.env.MCP_STORAGE_DIR, - allowedOrigins: [ - `http://localhost:${process.env.CLIENT_PORT || "6274"}`, - `http://127.0.0.1:${process.env.CLIENT_PORT || "6274"}`, - ], - sandboxUrl: sandboxController.getUrl() ?? undefined, - logger: process.env.MCP_LOG_FILE - ? pino( - { level: "info" }, - pino.destination({ - dest: process.env.MCP_LOG_FILE, - append: true, - mkdir: true, - }), - ) - : undefined, - }); - - // When no token was provided via env (e.g. `npm run dev` from web), log the generated token so the user can add it to the URL or paste in the token modal - if (!options.dangerouslyOmitAuth && !options.authToken && resolvedToken) { - const port = process.env.CLIENT_PORT || "6274"; - const host = process.env.HOST || "localhost"; - console.log( - `\nšŸ”‘ Inspector API token (add to URL or paste in token modal):\n ${resolvedToken}\n Or open: http://${host}:${port}/?${API_SERVER_ENV_VARS.AUTH_TOKEN}=${resolvedToken}\n`, - ); - } - const sandboxUrl = sandboxController.getUrl(); - if (sandboxUrl) { - if (server.httpServer) { - server.httpServer.once("listening", () => { - setImmediate(() => { - console.log(` āžœ Sandbox (MCP Apps): ${sandboxUrl}`); - }); - }); - } else { - console.log(` āžœ Sandbox (MCP Apps): ${sandboxUrl}`); - } - } - - // Convert Connect middleware to handle Hono app - const honoMiddleware = async ( - req: IncomingMessage, - res: ServerResponse, - next: (err?: unknown) => void, - ) => { - try { - // Only handle /api/* routes, let others pass through to Vite - const path = req.url || ""; - if (!path.startsWith("/api")) { - return next(); - } - - const url = `http://${req.headers.host}${path}`; - - const headers = new Headers(); - Object.entries(req.headers).forEach(([key, value]) => { - if (value) { - headers.set(key, Array.isArray(value) ? value.join(", ") : value); - } - }); - - const init: RequestInit = { - method: req.method, - headers, - }; - - // Handle body for non-GET requests - if (req.method !== "GET" && req.method !== "HEAD") { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - await new Promise((resolve) => { - req.on("end", () => resolve()); - }); - if (chunks.length > 0) { - init.body = Buffer.concat(chunks); - } - } - - const request = new Request(url, init); - const response = await honoApp.fetch(request); - - // Convert Web Standard Response back to Node res - res.statusCode = response.status; - response.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - - // For SSE streams, we need to stream data immediately without buffering - const isSSE = response.headers - .get("content-type") - ?.includes("text/event-stream"); - if (isSSE) { - // Disable buffering for SSE - res.setHeader("X-Accel-Buffering", "no"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - } - - if (response.body) { - // Flush headers immediately so the client gets 200 before any body chunks. - // Otherwise for SSE (no data until first event) reader.read() blocks and - // Node never sends headers, so the client's fetch() hangs. - res.flushHeaders?.(); - const reader = response.body.getReader(); - const pump = async () => { - try { - const { done, value } = await reader.read(); - if (done) { - res.end(); - } else { - // Write immediately without buffering - res.write(Buffer.from(value), (err) => { - if (err) { - console.error("[Hono Middleware] Write error:", err); - reader.cancel().catch(() => {}); - res.end(); - } - }); - // Continue pumping (don't await, but handle errors) - pump().catch((err) => { - console.error("[Hono Middleware] Pump error:", err); - reader.cancel().catch(() => {}); - res.end(); - }); - } - } catch (err) { - console.error("[Hono Middleware] Read error:", err); - reader.cancel().catch(() => {}); - res.end(); - } - }; - // Start pumping (don't await - let it run in background for SSE) - pump(); - } else { - res.end(); - } - } catch (error) { - next(error); - } - }; - - // Mount at root - check path ourselves to avoid Connect prefix stripping - // Only handle /api/* routes, let others pass through to Vite - server.middlewares.use(honoMiddleware); - }, - }; -} - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - // Inspector API auth token and DANGEROUSLY_OMIT_AUTH are passed via env (set by start script or user). - // When unset (e.g. running `npm run dev` from web), createRemoteApp generates one; plugin logs it below. - honoMiddlewarePlugin({ - authToken: - process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] || - process.env[LEGACY_AUTH_TOKEN_ENV] || - undefined, - dangerouslyOmitAuth: !!process.env.DANGEROUSLY_OMIT_AUTH, - }), - ], - server: { - host: true, - }, - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - // Prevent bundling Node.js-only modules - conditions: ["browser", "module", "import"], - }, - build: { - minify: false, - rollupOptions: { - output: { - manualChunks: undefined, - }, - external: [ - // Prevent bundling Node.js-only stdio transport code - "@modelcontextprotocol/sdk/client/stdio.js", - "cross-spawn", - "which", - ], - }, - }, - optimizeDeps: { - exclude: [ - // Exclude Node.js-only modules from pre-bundling - "@modelcontextprotocol/sdk/client/stdio.js", - "@modelcontextprotocol/inspector-core/mcp/node", - "@modelcontextprotocol/inspector-core/mcp/remote/node", - "cross-spawn", - "which", - ], - }, -});