diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml new file mode 100644 index 00000000..f40dd582 --- /dev/null +++ b/.github/workflows/e2e_tests.yml @@ -0,0 +1,74 @@ +name: Playwright Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + timeout-minutes: 5 + runs-on: ubuntu-latest + + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwoff1 + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + # Cache Playwright browsers + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright # The default Playwright cache path + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install dependencies + run: npm ci + + - name: Install Playwright dependencies + run: npx playwright install-deps + + - name: Install Playwright and browsers unless cached + run: npx playwright install --with-deps + if: steps.cache-playwright.outputs.cache-hit != 'true' + + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright Report and Screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + client/playwright-report/ + client/test-results/ + client/results.json + retention-days: 2 + + - name: Publish Playwright Test Summary + uses: daun/playwright-report-summary@v3 + if: always() + with: + create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + report-file: client/results.json + comment-title: "šŸŽ­ Playwright E2E Test Results" + job-summary: true + icon-style: "emojis" + custom-info: | + **Test Environment:** Ubuntu Latest, Node.js 18 + **Browsers:** Chromium, Firefox + + šŸ“Š [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) + test-command: "npm run test:e2e" diff --git a/.gitignore b/.gitignore index 60faf653..8e184de8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo cli/build test-output +client/playwright-report/ +client/results.json +client/test-results/ + diff --git a/CLAUDE.md b/CLAUDE.md index e62fd188..ec826b1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,4 +30,4 @@ The project is organized as a monorepo with workspaces: - `client/`: React frontend with Vite, TypeScript and Tailwind - `server/`: Express backend with TypeScript -- `bin/`: CLI scripts +- `cli/`: Command-line interface for testing and invoking MCP server methods directly diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72502f96..1eeeb864 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Create a new branch for your changes 2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. -3. Test changes locally by running `npm test` +3. Test changes locally by running `npm test` and `npm run test:e2e` 4. Update documentation as needed 5. Use clear commit messages explaining your changes 6. Verify all changes work as expected diff --git a/README.md b/README.md index ae82a695..94efe83c 100644 --- a/README.md +++ b/README.md @@ -148,9 +148,9 @@ The MCP Inspector proxy server requires authentication by default. When starting http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 ``` -This token must be included as a Bearer token in the Authorization header for all requests to the server. When authentication is enabled, auto-open is disabled by default to ensure you use the secure URL. +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. -**Recommended: Use the pre-filled URL** - Click or copy the link shown in the console to open the inspector with the token already configured. +**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: @@ -188,13 +188,13 @@ ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274,http://localhost:800 The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI: -| Setting | Description | Default | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- | -| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | -| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | -| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | -| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | -| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true | +| Setting | Description | Default | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | +| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | +| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts (works with authentication enabled). Only as environment var, not configurable in browser. | true | These settings can be adjusted in real-time through the UI and will persist across sessions. diff --git a/cli/package.json b/cli/package.json index f1ff1c33..382eb1b4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/bin/start.js b/client/bin/start.js index ccce7ade..ad75078a 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -2,8 +2,9 @@ import open from "open"; import { resolve, dirname } from "path"; -import { spawnPromise } from "spawn-rx"; +import { spawnPromise, spawn } from "spawn-rx"; import { fileURLToPath } from "url"; +import { randomBytes } from "crypto"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -11,6 +12,157 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } +async function startDevServer(serverOptions) { + const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } = + serverOptions; + const serverCommand = "npx"; + const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; + const isWindows = process.platform === "win32"; + + const spawnOptions = { + cwd: resolve(__dirname, "../..", "server"), + env: { + ...process.env, + PORT: SERVER_PORT, + CLIENT_PORT: CLIENT_PORT, + MCP_PROXY_TOKEN: sessionToken, + MCP_ENV_VARS: JSON.stringify(envVars), + }, + signal: abort.signal, + echoOutput: true, + }; + + // For Windows, we need to use stdin: 'ignore' to simulate < NUL + if (isWindows) { + spawnOptions.stdin = "ignore"; + } + + const server = spawn(serverCommand, serverArgs, spawnOptions); + + // Give server time to start + const serverOk = await Promise.race([ + new Promise((resolve) => { + server.subscribe({ + complete: () => resolve(false), + error: () => resolve(false), + next: () => {}, // We're using echoOutput + }); + }), + delay(3000).then(() => true), + ]); + + return { server, serverOk }; +} + +async function startProdServer(serverOptions) { + const { + SERVER_PORT, + CLIENT_PORT, + sessionToken, + envVars, + abort, + command, + mcpServerArgs, + } = serverOptions; + const inspectorServerPath = resolve( + __dirname, + "../..", + "server", + "build", + "index.js", + ); + + const server = spawnPromise( + "node", + [ + inspectorServerPath, + ...(command ? [`--env`, command] : []), + ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ], + { + env: { + ...process.env, + PORT: SERVER_PORT, + CLIENT_PORT: CLIENT_PORT, + MCP_PROXY_TOKEN: sessionToken, + MCP_ENV_VARS: JSON.stringify(envVars), + }, + signal: abort.signal, + echoOutput: true, + }, + ); + + // Make sure server started before starting client + const serverOk = await Promise.race([server, delay(2 * 1000)]); + + return { server, serverOk }; +} + +async function startDevClient(clientOptions) { + const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } = + clientOptions; + const clientCommand = "npx"; + const clientArgs = ["vite", "--port", CLIENT_PORT]; + + const client = spawn(clientCommand, clientArgs, { + cwd: resolve(__dirname, ".."), + env: { ...process.env, PORT: CLIENT_PORT }, + signal: abort.signal, + echoOutput: true, + }); + + // Auto-open browser after vite starts + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + const url = authDisabled + ? `http://127.0.0.1:${CLIENT_PORT}` + : `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + + // Give vite time to start before opening browser + setTimeout(() => { + open(url); + console.log(`\nšŸ”— Opening browser at: ${url}\n`); + }, 3000); + } + + await new Promise((resolve) => { + client.subscribe({ + complete: resolve, + error: (err) => { + if (!cancelled || process.env.DEBUG) { + console.error("Client error:", err); + } + resolve(null); + }, + next: () => {}, // We're using echoOutput + }); + }); +} + +async function startProdClient(clientOptions) { + const { CLIENT_PORT, authDisabled, sessionToken, abort } = clientOptions; + const inspectorClientPath = resolve( + __dirname, + "../..", + "client", + "bin", + "client.js", + ); + + // Auto-open browser with token + if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { + const url = authDisabled + ? `http://127.0.0.1:${CLIENT_PORT}` + : `http://127.0.0.1:${CLIENT_PORT}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; + open(url); + } + + await spawnPromise("node", [inspectorClientPath], { + env: { ...process.env, PORT: CLIENT_PORT }, + signal: abort.signal, + echoOutput: true, + }); +} + async function main() { // Parse command line arguments const args = process.argv.slice(2); @@ -18,6 +170,7 @@ async function main() { const mcpServerArgs = []; let command = null; let parsingFlags = true; + let isDev = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -27,6 +180,11 @@ async function main() { continue; } + if (parsingFlags && arg === "--dev") { + isDev = true; + continue; + } + if (parsingFlags && arg === "-e" && i + 1 < args.length) { const envVar = args[++i]; const equalsIndex = envVar.indexOf("="); @@ -38,34 +196,25 @@ async function main() { } else { envVars[envVar] = ""; } - } else if (!command) { + } else if (!command && !isDev) { command = arg; - } else { + } else if (!isDev) { mcpServerArgs.push(arg); } } - const inspectorServerPath = resolve( - __dirname, - "../..", - "server", - "build", - "index.js", - ); - - // Path to the client entry point - const inspectorClientPath = resolve( - __dirname, - "../..", - "client", - "bin", - "client.js", - ); - const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274"; const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; - console.log("Starting MCP inspector..."); + console.log( + isDev + ? "Starting MCP inspector in development mode..." + : "Starting MCP inspector...", + ); + + // Generate session token for authentication + const sessionToken = randomBytes(32).toString("hex"); + const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; const abort = new AbortController(); @@ -74,42 +223,41 @@ async function main() { cancelled = true; abort.abort(); }); - let server, serverOk; - try { - server = spawnPromise( - "node", - [ - inspectorServerPath, - ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), - ], - { - env: { - ...process.env, - PORT: SERVER_PORT, - MCP_ENV_VARS: JSON.stringify(envVars), - }, - signal: abort.signal, - echoOutput: true, - }, - ); - // Make sure server started before starting client - serverOk = await Promise.race([server, delay(2 * 1000)]); + let server, serverOk; + + try { + const serverOptions = { + SERVER_PORT, + CLIENT_PORT, + sessionToken, + envVars, + abort, + command, + mcpServerArgs, + }; + + const result = isDev + ? await startDevServer(serverOptions) + : await startProdServer(serverOptions); + + server = result.server; + serverOk = result.serverOk; } catch (error) {} if (serverOk) { try { - // Only auto-open when auth is disabled - const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; - if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && authDisabled) { - open(`http://127.0.0.1:${CLIENT_PORT}`); - } - await spawnPromise("node", [inspectorClientPath], { - env: { ...process.env, PORT: CLIENT_PORT }, - signal: abort.signal, - echoOutput: true, - }); + const clientOptions = { + CLIENT_PORT, + authDisabled, + sessionToken, + abort, + cancelled, + }; + + await (isDev + ? startDevClient(clientOptions) + : startProdClient(clientOptions)); } catch (e) { if (!cancelled || process.env.DEBUG) throw e; } diff --git a/client/e2e/global-teardown.js b/client/e2e/global-teardown.js new file mode 100644 index 00000000..9308d940 --- /dev/null +++ b/client/e2e/global-teardown.js @@ -0,0 +1,18 @@ +import { rimraf } from "rimraf"; + +async function globalTeardown() { + if (!process.env.CI) { + console.log("Cleaning up test-results directory..."); + // Add a small delay to ensure all Playwright files are written + await new Promise((resolve) => setTimeout(resolve, 100)); + await rimraf("./e2e/test-results"); + console.log("Test-results directory cleaned up."); + } +} + +export default globalTeardown; + +// Call the function when this script is run directly +if (import.meta.url === `file://${process.argv[1]}`) { + globalTeardown().catch(console.error); +} diff --git a/client/e2e/transport-type-dropdown.spec.ts b/client/e2e/transport-type-dropdown.spec.ts new file mode 100644 index 00000000..e4ac8fbb --- /dev/null +++ b/client/e2e/transport-type-dropdown.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; + +// Adjust the URL if your dev server runs on a different port +const APP_URL = "http://localhost:6274/"; + +test.describe("Transport Type Dropdown", () => { + test("should have options for STDIO, SSE, and Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown + await selectTrigger.click(); + + // Check for the three options + await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible(); + await expect(page.getByRole("option", { name: "SSE" })).toBeVisible(); + await expect( + page.getByRole("option", { name: "Streamable HTTP" }), + ).toBeVisible(); + }); + + test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select STDIO + await selectTrigger.click(); + await page.getByRole("option", { name: "STDIO" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that Command and Arguments fields are visible + await expect(page.locator("#command-input")).toBeVisible(); + await expect(page.locator("#arguments-input")).toBeVisible(); + + // Check that URL field is not visible + await expect(page.locator("#sse-url-input")).not.toBeVisible(); + + // Also verify the labels are present + await expect(page.getByText("Command")).toBeVisible(); + await expect(page.getByText("Arguments")).toBeVisible(); + await expect(page.getByText("URL")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select SSE + await selectTrigger.click(); + await page.getByRole("option", { name: "SSE" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select Streamable HTTP + await selectTrigger.click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); +}); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index b4d81cfc..8652eda5 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -21,6 +21,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], // Exclude the same patterns from coverage reports @@ -28,6 +29,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], }; diff --git a/client/package.json b/client/package.json index 37a0675b..12438aeb 100644 --- a/client/package.json +++ b/client/package.json @@ -20,10 +20,12 @@ "lint": "eslint .", "preview": "vite preview --port 6274", "test": "jest --config jest.config.cjs", - "test:watch": "jest --config jest.config.cjs --watch" + "test:watch": "jest --config jest.config.cjs --watch", + "test:e2e": "playwright test e2e && npm run cleanup:e2e", + "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "@radix-ui/react-checkbox": "^1.1.4", "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 00000000..570dd054 --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + /* Run your local dev server before starting the tests */ + webServer: { + cwd: "..", + command: "npm run dev", + url: "http://localhost:6274", + reuseExistingServer: !process.env.CI, + }, + + testDir: "./e2e", + outputDir: "./e2e/test-results", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report" }], + ["json", { outputFile: "results.json" }], + ["line"], + ] + : [["line"]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:6274", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Take screenshots on failure */ + screenshot: "only-on-failure", + + /* Record video on failure */ + video: "retain-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + // Skip WebKit on macOS due to compatibility issues + ...(process.platform !== "darwin" + ? [ + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ] + : []), + ], +}); diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index b9b67c6c..0e260556 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -156,7 +156,7 @@ export const OAuthFlowProgress = ({ {authState.resourceMetadataError && (

- ā„¹ļø No resource metadata available from{" "} + ā„¹ļø Problem with resource metadata from{" "} ({ startAuthorization: jest.fn(), exchangeAuthorization: jest.fn(), discoverOAuthProtectedResourceMetadata: jest.fn(), + selectResourceURL: jest.fn(), })); // Import the functions to get their types @@ -88,7 +89,7 @@ describe("AuthDebugger", () => { const defaultAuthState = EMPTY_DEBUGGER_STATE; const defaultProps = { - serverUrl: "https://example.com", + serverUrl: "https://example.com/mcp", onBack: jest.fn(), authState: defaultAuthState, updateAuthState: jest.fn(), @@ -203,7 +204,7 @@ describe("AuthDebugger", () => { // Should first discover and save OAuth metadata expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - new URL("https://example.com"), + new URL("https://example.com/"), ); // Check that updateAuthState was called with the right info message @@ -320,6 +321,7 @@ describe("AuthDebugger", () => { isInitiatingAuth: false, resourceMetadata: null, resourceMetadataError: null, + resource: null, oauthTokens: null, oauthStep: "metadata_discovery", latestError: null, @@ -361,7 +363,7 @@ describe("AuthDebugger", () => { }); expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - new URL("https://example.com"), + new URL("https://example.com/"), ); }); @@ -496,11 +498,11 @@ describe("AuthDebugger", () => { it("should successfully fetch and display protected resource metadata", async () => { const updateAuthState = jest.fn(); const mockResourceMetadata = { - resource: "https://example.com/api", + resource: "https://example.com/mcp", authorization_servers: ["https://custom-auth.example.com"], bearer_methods_supported: ["header", "body"], - resource_documentation: "https://example.com/api/docs", - resource_policy_uri: "https://example.com/api/policy", + resource_documentation: "https://example.com/mcp/docs", + resource_policy_uri: "https://example.com/mcp/policy", }; // Mock successful metadata discovery @@ -538,7 +540,7 @@ describe("AuthDebugger", () => { // Wait for the metadata to be fetched await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com", + "https://example.com/mcp", ); }); @@ -584,7 +586,7 @@ describe("AuthDebugger", () => { // Wait for the metadata fetch to fail await waitFor(() => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com", + "https://example.com/mcp", ); }); @@ -594,7 +596,7 @@ describe("AuthDebugger", () => { expect.objectContaining({ resourceMetadataError: mockError, // Should use the original server URL as fallback - authServerUrl: new URL("https://example.com"), + authServerUrl: new URL("https://example.com/"), oauthStep: "client_registration", }), ); @@ -602,7 +604,7 @@ describe("AuthDebugger", () => { // Verify that regular OAuth metadata discovery was still called expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - new URL("https://example.com"), + new URL("https://example.com/"), ); }); }); diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 5e8113ef..f8f927c6 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -30,6 +30,7 @@ export interface AuthDebuggerState { oauthStep: OAuthStep; resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadataError: Error | null; + resource: URL | null; authServerUrl: URL | null; oauthMetadata: OAuthMetadata | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; @@ -47,6 +48,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = { oauthMetadata: null, resourceMetadata: null, resourceMetadataError: null, + resource: null, authServerUrl: null, oauthClientInfo: null, authorizationUrl: null, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 5f10a783..d87b3ecd 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -6,6 +6,7 @@ import { startAuthorization, exchangeAuthorization, discoverOAuthProtectedResourceMetadata, + selectResourceURL, } from "@modelcontextprotocol/sdk/client/auth.js"; import { OAuthMetadataSchema, @@ -29,17 +30,15 @@ export const oauthTransitions: Record = { metadata_discovery: { canTransition: async () => true, execute: async (context) => { - let authServerUrl = new URL(context.serverUrl); + // Default to discovering from the server's URL + let authServerUrl = new URL("/", context.serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | null = null; let resourceMetadataError: Error | null = null; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( context.serverUrl, ); - if ( - resourceMetadata && - resourceMetadata.authorization_servers?.length - ) { + if (resourceMetadata?.authorization_servers?.length) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); } } catch (e) { @@ -50,6 +49,13 @@ export const oauthTransitions: Record = { } } + const resource: URL | undefined = await selectResourceURL( + context.serverUrl, + context.provider, + // we default to null, so swap it for undefined if not set + resourceMetadata ?? undefined, + ); + const metadata = await discoverOAuthMetadata(authServerUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); @@ -58,6 +64,7 @@ export const oauthTransitions: Record = { context.provider.saveServerMetadata(parsedMetadata); context.updateState({ resourceMetadata, + resource, resourceMetadataError, authServerUrl, oauthMetadata: parsedMetadata, @@ -113,6 +120,7 @@ export const oauthTransitions: Record = { clientInformation, redirectUrl: context.provider.redirectUrl, scope, + resource: context.state.resource ?? undefined, }, ); @@ -163,6 +171,7 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, + resource: context.state.resource ?? undefined, }); context.provider.saveTokens(tokens); diff --git a/package-lock.json b/package-lock.json index a54bf664..0e74be50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.14.3", "@modelcontextprotocol/inspector-client": "^0.14.3", "@modelcontextprotocol/inspector-server": "^0.14.3", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -29,6 +29,7 @@ "mcp-inspector": "cli/build/cli.js" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", @@ -43,7 +44,7 @@ "version": "0.14.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -66,7 +67,7 @@ "version": "0.14.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -2005,9 +2006,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", - "integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.1.tgz", + "integrity": "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", @@ -2070,6 +2071,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -8519,6 +8536,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -10923,7 +10987,7 @@ "version": "0.14.3", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index 7e7be67f..8f48f85a 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,14 @@ "build-client": "cd client && npm run build", "build-cli": "cd cli && npm run build", "clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install", - "dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"", - "dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"", + "dev": "node client/bin/start.js --dev", + "dev:windows": "node client/bin/start.js --dev", "start": "node client/bin/start.js", "start-server": "cd server && npm run start", "start-client": "cd client && npm run preview", "test": "npm run prettier-check && cd client && npm test", "test-cli": "cd cli && npm run test", + "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", @@ -46,7 +47,7 @@ "@modelcontextprotocol/inspector-cli": "^0.14.3", "@modelcontextprotocol/inspector-client": "^0.14.3", "@modelcontextprotocol/inspector-server": "^0.14.3", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.1", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -55,6 +56,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", diff --git a/server/package.json b/server/package.json index 8f81a9df..9c248635 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.13.0", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/server/src/index.ts b/server/src/index.ts index 38d62b71..7653597a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -89,7 +89,9 @@ app.use((req, res, next) => { const webAppTransports: Map = new Map(); // Web app transports by web app sessionId const serverTransports: Map = new Map(); // Server Transports by web app sessionId -const sessionToken = randomBytes(32).toString("hex"); +// Use provided token from environment or generate a new one +const sessionToken = + process.env.MCP_PROXY_TOKEN || randomBytes(32).toString("hex"); const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; // Origin validation middleware to prevent DNS rebinding attacks @@ -544,7 +546,7 @@ server.on("listening", () => { const clientPort = process.env.CLIENT_PORT || "6274"; const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; console.log( - `\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n (Auto-open is disabled when authentication is enabled)\n`, + `\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n`, ); } else { console.log(