Merge branch 'main' into sebastian/patch-remove-addition-slash

This commit is contained in:
Sebastian Ko 2025-06-24 05:25:18 -04:00 • committed by GitHub
commit 66abc3bfac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 605 additions and 93 deletions

74
.github/workflows/e2e_tests.yml vendored Normal file
View file

@ -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"

4
.gitignore vendored
View file

@ -9,3 +9,7 @@ client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo client/tsconfig.node.tsbuildinfo
cli/build cli/build
test-output test-output
client/playwright-report/
client/results.json
client/test-results/

View file

@ -30,4 +30,4 @@ The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind - `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript - `server/`: Express backend with TypeScript
- `bin/`: CLI scripts - `cli/`: Command-line interface for testing and invoking MCP server methods directly

View file

@ -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 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. 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 4. Update documentation as needed
5. Use clear commit messages explaining your changes 5. Use clear commit messages explaining your changes
6. Verify all changes work as expected 6. Verify all changes work as expected

View file

@ -148,9 +148,9 @@ The MCP Inspector proxy server requires authentication by default. When starting
http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=3a1c267fad21f7150b7d624c160b7f09b0b8c4f623c7107bbf13378f051538d4 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: **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: The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Setting | Description | Default | | Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | | `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_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_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_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 | | `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. These settings can be adjusted in real-time through the UI and will persist across sessions.

View file

@ -21,7 +21,7 @@
}, },
"devDependencies": {}, "devDependencies": {},
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
} }

View file

@ -2,8 +2,9 @@
import open from "open"; import open from "open";
import { resolve, dirname } from "path"; import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx"; import { spawnPromise, spawn } from "spawn-rx";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { randomBytes } from "crypto";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@ -11,6 +12,157 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true)); 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() { async function main() {
// Parse command line arguments // Parse command line arguments
const args = process.argv.slice(2); const args = process.argv.slice(2);
@ -18,6 +170,7 @@ async function main() {
const mcpServerArgs = []; const mcpServerArgs = [];
let command = null; let command = null;
let parsingFlags = true; let parsingFlags = true;
let isDev = false;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
@ -27,6 +180,11 @@ async function main() {
continue; continue;
} }
if (parsingFlags && arg === "--dev") {
isDev = true;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) { if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i]; const envVar = args[++i];
const equalsIndex = envVar.indexOf("="); const equalsIndex = envVar.indexOf("=");
@ -38,34 +196,25 @@ async function main() {
} else { } else {
envVars[envVar] = ""; envVars[envVar] = "";
} }
} else if (!command) { } else if (!command && !isDev) {
command = arg; command = arg;
} else { } else if (!isDev) {
mcpServerArgs.push(arg); 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 CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277"; 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(); const abort = new AbortController();
@ -74,42 +223,41 @@ async function main() {
cancelled = true; cancelled = true;
abort.abort(); 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 let server, serverOk;
serverOk = await Promise.race([server, delay(2 * 1000)]);
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) {} } catch (error) {}
if (serverOk) { if (serverOk) {
try { try {
// Only auto-open when auth is disabled const clientOptions = {
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; CLIENT_PORT,
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && authDisabled) { authDisabled,
open(`http://127.0.0.1:${CLIENT_PORT}`); sessionToken,
} abort,
await spawnPromise("node", [inspectorClientPath], { cancelled,
env: { ...process.env, PORT: CLIENT_PORT }, };
signal: abort.signal,
echoOutput: true, await (isDev
}); ? startDevClient(clientOptions)
: startProdClient(clientOptions));
} catch (e) { } catch (e) {
if (!cancelled || process.env.DEBUG) throw e; if (!cancelled || process.env.DEBUG) throw e;
} }

View file

@ -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);
}

View file

@ -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();
});
});

View file

@ -21,6 +21,7 @@ module.exports = {
"/node_modules/", "/node_modules/",
"/dist/", "/dist/",
"/bin/", "/bin/",
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$", "\\.config\\.(js|ts|cjs|mjs)$",
], ],
// Exclude the same patterns from coverage reports // Exclude the same patterns from coverage reports
@ -28,6 +29,7 @@ module.exports = {
"/node_modules/", "/node_modules/",
"/dist/", "/dist/",
"/bin/", "/bin/",
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$", "\\.config\\.(js|ts|cjs|mjs)$",
], ],
}; };

View file

@ -20,10 +20,12 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview --port 6274", "preview": "vite preview --port 6274",
"test": "jest --config jest.config.cjs", "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": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6", "ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",

View file

@ -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"] },
},
]
: []),
],
});

View file

@ -156,7 +156,7 @@ export const OAuthFlowProgress = ({
{authState.resourceMetadataError && ( {authState.resourceMetadataError && (
<div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md"> <div className="mt-2 p-3 border border-blue-300 bg-blue-50 rounded-md">
<p className="text-sm font-medium text-blue-700"> <p className="text-sm font-medium text-blue-700">
ℹ️ No resource metadata available from{" "} ℹ️ Problem with resource metadata from{" "}
<a <a
href={ href={
new URL( new URL(

View file

@ -41,6 +41,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
startAuthorization: jest.fn(), startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(), exchangeAuthorization: jest.fn(),
discoverOAuthProtectedResourceMetadata: jest.fn(), discoverOAuthProtectedResourceMetadata: jest.fn(),
selectResourceURL: jest.fn(),
})); }));
// Import the functions to get their types // Import the functions to get their types
@ -88,7 +89,7 @@ describe("AuthDebugger", () => {
const defaultAuthState = EMPTY_DEBUGGER_STATE; const defaultAuthState = EMPTY_DEBUGGER_STATE;
const defaultProps = { const defaultProps = {
serverUrl: "https://example.com", serverUrl: "https://example.com/mcp",
onBack: jest.fn(), onBack: jest.fn(),
authState: defaultAuthState, authState: defaultAuthState,
updateAuthState: jest.fn(), updateAuthState: jest.fn(),
@ -203,7 +204,7 @@ describe("AuthDebugger", () => {
// Should first discover and save OAuth metadata // Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"), new URL("https://example.com/"),
); );
// Check that updateAuthState was called with the right info message // Check that updateAuthState was called with the right info message
@ -320,6 +321,7 @@ describe("AuthDebugger", () => {
isInitiatingAuth: false, isInitiatingAuth: false,
resourceMetadata: null, resourceMetadata: null,
resourceMetadataError: null, resourceMetadataError: null,
resource: null,
oauthTokens: null, oauthTokens: null,
oauthStep: "metadata_discovery", oauthStep: "metadata_discovery",
latestError: null, latestError: null,
@ -361,7 +363,7 @@ describe("AuthDebugger", () => {
}); });
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( 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 () => { it("should successfully fetch and display protected resource metadata", async () => {
const updateAuthState = jest.fn(); const updateAuthState = jest.fn();
const mockResourceMetadata = { const mockResourceMetadata = {
resource: "https://example.com/api", resource: "https://example.com/mcp",
authorization_servers: ["https://custom-auth.example.com"], authorization_servers: ["https://custom-auth.example.com"],
bearer_methods_supported: ["header", "body"], bearer_methods_supported: ["header", "body"],
resource_documentation: "https://example.com/api/docs", resource_documentation: "https://example.com/mcp/docs",
resource_policy_uri: "https://example.com/api/policy", resource_policy_uri: "https://example.com/mcp/policy",
}; };
// Mock successful metadata discovery // Mock successful metadata discovery
@ -538,7 +540,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata to be fetched // Wait for the metadata to be fetched
await waitFor(() => { await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com", "https://example.com/mcp",
); );
}); });
@ -584,7 +586,7 @@ describe("AuthDebugger", () => {
// Wait for the metadata fetch to fail // Wait for the metadata fetch to fail
await waitFor(() => { await waitFor(() => {
expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
"https://example.com", "https://example.com/mcp",
); );
}); });
@ -594,7 +596,7 @@ describe("AuthDebugger", () => {
expect.objectContaining({ expect.objectContaining({
resourceMetadataError: mockError, resourceMetadataError: mockError,
// Should use the original server URL as fallback // Should use the original server URL as fallback
authServerUrl: new URL("https://example.com"), authServerUrl: new URL("https://example.com/"),
oauthStep: "client_registration", oauthStep: "client_registration",
}), }),
); );
@ -602,7 +604,7 @@ describe("AuthDebugger", () => {
// Verify that regular OAuth metadata discovery was still called // Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
new URL("https://example.com"), new URL("https://example.com/"),
); );
}); });
}); });

View file

@ -30,6 +30,7 @@ export interface AuthDebuggerState {
oauthStep: OAuthStep; oauthStep: OAuthStep;
resourceMetadata: OAuthProtectedResourceMetadata | null; resourceMetadata: OAuthProtectedResourceMetadata | null;
resourceMetadataError: Error | null; resourceMetadataError: Error | null;
resource: URL | null;
authServerUrl: URL | null; authServerUrl: URL | null;
oauthMetadata: OAuthMetadata | null; oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
@ -47,6 +48,7 @@ export const EMPTY_DEBUGGER_STATE: AuthDebuggerState = {
oauthMetadata: null, oauthMetadata: null,
resourceMetadata: null, resourceMetadata: null,
resourceMetadataError: null, resourceMetadataError: null,
resource: null,
authServerUrl: null, authServerUrl: null,
oauthClientInfo: null, oauthClientInfo: null,
authorizationUrl: null, authorizationUrl: null,

View file

@ -6,6 +6,7 @@ import {
startAuthorization, startAuthorization,
exchangeAuthorization, exchangeAuthorization,
discoverOAuthProtectedResourceMetadata, discoverOAuthProtectedResourceMetadata,
selectResourceURL,
} from "@modelcontextprotocol/sdk/client/auth.js"; } from "@modelcontextprotocol/sdk/client/auth.js";
import { import {
OAuthMetadataSchema, OAuthMetadataSchema,
@ -29,17 +30,15 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
metadata_discovery: { metadata_discovery: {
canTransition: async () => true, canTransition: async () => true,
execute: async (context) => { 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 resourceMetadata: OAuthProtectedResourceMetadata | null = null;
let resourceMetadataError: Error | null = null; let resourceMetadataError: Error | null = null;
try { try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata( resourceMetadata = await discoverOAuthProtectedResourceMetadata(
context.serverUrl, context.serverUrl,
); );
if ( if (resourceMetadata?.authorization_servers?.length) {
resourceMetadata &&
resourceMetadata.authorization_servers?.length
) {
authServerUrl = new URL(resourceMetadata.authorization_servers[0]); authServerUrl = new URL(resourceMetadata.authorization_servers[0]);
} }
} catch (e) { } catch (e) {
@ -50,6 +49,13 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
} }
} }
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); const metadata = await discoverOAuthMetadata(authServerUrl);
if (!metadata) { if (!metadata) {
throw new Error("Failed to discover OAuth metadata"); throw new Error("Failed to discover OAuth metadata");
@ -58,6 +64,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
context.provider.saveServerMetadata(parsedMetadata); context.provider.saveServerMetadata(parsedMetadata);
context.updateState({ context.updateState({
resourceMetadata, resourceMetadata,
resource,
resourceMetadataError, resourceMetadataError,
authServerUrl, authServerUrl,
oauthMetadata: parsedMetadata, oauthMetadata: parsedMetadata,
@ -113,6 +120,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
clientInformation, clientInformation,
redirectUrl: context.provider.redirectUrl, redirectUrl: context.provider.redirectUrl,
scope, scope,
resource: context.state.resource ?? undefined,
}, },
); );
@ -163,6 +171,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
authorizationCode: context.state.authorizationCode, authorizationCode: context.state.authorizationCode,
codeVerifier, codeVerifier,
redirectUri: context.provider.redirectUrl, redirectUri: context.provider.redirectUrl,
resource: context.state.resource ?? undefined,
}); });
context.provider.saveTokens(tokens); context.provider.saveTokens(tokens);

78
package-lock.json generated
View file

@ -17,7 +17,7 @@
"@modelcontextprotocol/inspector-cli": "^0.14.3", "@modelcontextprotocol/inspector-cli": "^0.14.3",
"@modelcontextprotocol/inspector-client": "^0.14.3", "@modelcontextprotocol/inspector-client": "^0.14.3",
"@modelcontextprotocol/inspector-server": "^0.14.3", "@modelcontextprotocol/inspector-server": "^0.14.3",
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"open": "^10.1.0", "open": "^10.1.0",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
@ -29,6 +29,7 @@
"mcp-inspector": "cli/build/cli.js" "mcp-inspector": "cli/build/cli.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
@ -43,7 +44,7 @@
"version": "0.14.3", "version": "0.14.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
}, },
@ -66,7 +67,7 @@
"version": "0.14.3", "version": "0.14.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
@ -2005,9 +2006,9 @@
"link": true "link": true
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.12.1", "version": "1.13.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.1.tgz",
"integrity": "sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==", "integrity": "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==",
"dependencies": { "dependencies": {
"ajv": "^6.12.6", "ajv": "^6.12.6",
"content-type": "^1.0.5", "content-type": "^1.0.5",
@ -2070,6 +2071,22 @@
"node": ">=14" "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": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@ -8519,6 +8536,53 @@
"node": ">=8" "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": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -10923,7 +10987,7 @@
"version": "0.14.3", "version": "0.14.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"ws": "^8.18.0", "ws": "^8.18.0",

View file

@ -27,13 +27,14 @@
"build-client": "cd client && npm run build", "build-client": "cd client && npm run build",
"build-cli": "cd cli && 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", "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": "node client/bin/start.js --dev",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows\"", "dev:windows": "node client/bin/start.js --dev",
"start": "node client/bin/start.js", "start": "node client/bin/start.js",
"start-server": "cd server && npm run start", "start-server": "cd server && npm run start",
"start-client": "cd client && npm run preview", "start-client": "cd client && npm run preview",
"test": "npm run prettier-check && cd client && npm test", "test": "npm run prettier-check && cd client && npm test",
"test-cli": "cd cli && npm run 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-fix": "prettier --write .",
"prettier-check": "prettier --check .", "prettier-check": "prettier --check .",
"lint": "prettier --check . && cd client && npm run lint", "lint": "prettier --check . && cd client && npm run lint",
@ -46,7 +47,7 @@
"@modelcontextprotocol/inspector-cli": "^0.14.3", "@modelcontextprotocol/inspector-cli": "^0.14.3",
"@modelcontextprotocol/inspector-client": "^0.14.3", "@modelcontextprotocol/inspector-client": "^0.14.3",
"@modelcontextprotocol/inspector-server": "^0.14.3", "@modelcontextprotocol/inspector-server": "^0.14.3",
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.1",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"open": "^10.1.0", "open": "^10.1.0",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
@ -55,6 +56,7 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",

View file

@ -27,7 +27,7 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1", "@modelcontextprotocol/sdk": "^1.13.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"ws": "^8.18.0", "ws": "^8.18.0",

View file

@ -89,7 +89,9 @@ app.use((req, res, next) => {
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId
const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // Server Transports by web app sessionId const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // 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; const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
// Origin validation middleware to prevent DNS rebinding attacks // Origin validation middleware to prevent DNS rebinding attacks
@ -544,7 +546,7 @@ server.on("listening", () => {
const clientPort = process.env.CLIENT_PORT || "6274"; const clientPort = process.env.CLIENT_PORT || "6274";
const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
console.log( 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 { } else {
console.log( console.log(