Merge branch 'main' into notification-expansion-state

This commit is contained in:
Ola Hungerford 2025-06-25 05:05:29 -07:00 • committed by GitHub
commit 4e68b93ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 574 additions and 40 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

@ -299,9 +299,12 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method resources
# List available prompts # List available prompts
npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/list
# Connect to a remote MCP server # Connect to a remote MCP server (default is SSE transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
# Connect to a remote MCP server (with Streamable HTTP transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http
# Call a tool on a remote server # 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 npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value

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

@ -44,7 +44,14 @@ console.log(`${colors.BLUE}- Resource-related options (--uri)${colors.NC}`);
console.log( console.log(
`${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`, `${colors.BLUE}- Prompt-related options (--prompt-name, --prompt-args)${colors.NC}`,
); );
console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}\n`); console.log(`${colors.BLUE}- Logging options (--log-level)${colors.NC}`);
console.log(
`${colors.BLUE}- Transport types (--transport http/sse/stdio)${colors.NC}`,
);
console.log(
`${colors.BLUE}- Transport inference from URL suffixes (/mcp, /sse)${colors.NC}`,
);
console.log(`\n`);
// Get directory paths // Get directory paths
const SCRIPTS_DIR = __dirname; const SCRIPTS_DIR = __dirname;
@ -62,9 +69,11 @@ if (!fs.existsSync(OUTPUT_DIR)) {
} }
// Create a temporary directory for test files // Create a temporary directory for test files
const TEMP_DIR = fs.mkdirSync(path.join(os.tmpdir(), "mcp-inspector-tests"), { const TEMP_DIR = path.join(os.tmpdir(), "mcp-inspector-tests");
recursive: true, fs.mkdirSync(TEMP_DIR, { recursive: true });
});
// Track servers for cleanup
let runningServers = [];
process.on("exit", () => { process.on("exit", () => {
try { try {
@ -74,6 +83,21 @@ process.on("exit", () => {
`${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`, `${colors.RED}Failed to remove temp directory: ${err.message}${colors.NC}`,
); );
} }
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
});
process.on("SIGINT", () => {
runningServers.forEach((server) => {
try {
process.kill(-server.pid);
} catch (e) {}
});
process.exit(1);
}); });
// Use the existing sample config file // Use the existing sample config file
@ -121,6 +145,11 @@ async function runBasicTest(testName, ...args) {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
const timeout = setTimeout(() => {
console.log(`${colors.YELLOW}Test timed out: ${testName}${colors.NC}`);
child.kill();
}, 10000);
// Pipe stdout and stderr to the output file // Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream); child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream); child.stderr.pipe(outputStream);
@ -135,6 +164,7 @@ async function runBasicTest(testName, ...args) {
}); });
child.on("close", (code) => { child.on("close", (code) => {
clearTimeout(timeout);
outputStream.end(); outputStream.end();
if (code === 0) { if (code === 0) {
@ -201,6 +231,13 @@ async function runErrorTest(testName, ...args) {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
const timeout = setTimeout(() => {
console.log(
`${colors.YELLOW}Error test timed out: ${testName}${colors.NC}`,
);
child.kill();
}, 10000);
// Pipe stdout and stderr to the output file // Pipe stdout and stderr to the output file
child.stdout.pipe(outputStream); child.stdout.pipe(outputStream);
child.stderr.pipe(outputStream); child.stderr.pipe(outputStream);
@ -215,6 +252,7 @@ async function runErrorTest(testName, ...args) {
}); });
child.on("close", (code) => { child.on("close", (code) => {
clearTimeout(timeout);
outputStream.end(); outputStream.end();
// For error tests, we expect a non-zero exit code // For error tests, we expect a non-zero exit code
@ -611,6 +649,79 @@ async function runTests() {
"debug", "debug",
); );
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
console.log(
`${colors.BLUE}Starting server-everything in streamableHttp mode.${colors.NC}`,
);
const httpServer = spawn(
"npx",
["@modelcontextprotocol/server-everything", "streamableHttp"],
{
detached: true,
stdio: "ignore",
},
);
runningServers.push(httpServer);
await new Promise((resolve) => setTimeout(resolve, 3000));
// Test 25: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
"--cli",
"--method",
"tools/list",
);
// Test 26: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001",
"--transport",
"http",
"--cli",
"--method",
"tools/list",
);
// Test 27: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
"--transport",
"http",
"--cli",
"--method",
"tools/list",
);
// Test 28: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
"--transport",
"sse",
"--cli",
"--method",
"tools/list",
);
// Kill HTTP server
try {
process.kill(-httpServer.pid);
console.log(
`${colors.BLUE}HTTP server killed, waiting for port to be released...${colors.NC}`,
);
} catch (e) {
console.log(
`${colors.RED}Error killing HTTP server: ${e.message}${colors.NC}`,
);
}
// Print test summary // Print test summary
console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`); console.log(`\n${colors.YELLOW}=== Test Summary ===${colors.NC}`);
console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`); console.log(`${colors.GREEN}Passed: ${PASSED_TESTS}${colors.NC}`);

View file

@ -29,9 +29,13 @@ type Args = {
logLevel?: LogLevel; logLevel?: LogLevel;
toolName?: string; toolName?: string;
toolArg?: Record<string, string>; toolArg?: Record<string, string>;
transport?: "sse" | "stdio" | "http";
}; };
function createTransportOptions(target: string[]): TransportOptions { function createTransportOptions(
target: string[],
transport?: "sse" | "stdio" | "http",
): TransportOptions {
if (target.length === 0) { if (target.length === 0) {
throw new Error( throw new Error(
"Target is required. Specify a URL or a command to execute.", "Target is required. Specify a URL or a command to execute.",
@ -50,8 +54,30 @@ function createTransportOptions(target: string[]): TransportOptions {
throw new Error("Arguments cannot be passed to a URL-based MCP server."); throw new Error("Arguments cannot be passed to a URL-based MCP server.");
} }
let transportType: "sse" | "stdio" | "http";
if (transport) {
if (!isUrl && transport !== "stdio") {
throw new Error("Only stdio transport can be used with local commands.");
}
if (isUrl && transport === "stdio") {
throw new Error("stdio transport cannot be used with URLs.");
}
transportType = transport;
} else if (isUrl) {
const url = new URL(command);
if (url.pathname.endsWith("/mcp")) {
transportType = "http";
} else if (url.pathname.endsWith("/sse")) {
transportType = "sse";
} else {
transportType = "sse";
}
} else {
transportType = "stdio";
}
return { return {
transportType: isUrl ? "sse" : "stdio", transportType,
command: isUrl ? undefined : command, command: isUrl ? undefined : command,
args: isUrl ? undefined : commandArgs, args: isUrl ? undefined : commandArgs,
url: isUrl ? command : undefined, url: isUrl ? command : undefined,
@ -59,7 +85,7 @@ function createTransportOptions(target: string[]): TransportOptions {
} }
async function callMethod(args: Args): Promise<void> { async function callMethod(args: Args): Promise<void> {
const transportOptions = createTransportOptions(args.target); const transportOptions = createTransportOptions(args.target, args.transport);
const transport = createTransport(transportOptions); const transport = createTransport(transportOptions);
const client = new Client({ const client = new Client({
name: "inspector-cli", name: "inspector-cli",
@ -214,6 +240,22 @@ function parseArgs(): Args {
return value as LogLevel; return value as LogLevel;
}, },
)
//
// Transport options
//
.option(
"--transport <type>",
"Transport type (sse, http, or stdio). Auto-detected from URL: /mcp → http, /sse → sse, commands → stdio",
(value: string) => {
const validTransports = ["sse", "http", "stdio"];
if (!validTransports.includes(value)) {
throw new Error(
`Invalid transport type: ${value}. Valid types are: ${validTransports.join(", ")}`,
);
}
return value as "sse" | "http" | "stdio";
},
); );
// Parse only the arguments before -- // Parse only the arguments before --

View file

@ -3,11 +3,12 @@ import {
getDefaultEnvironment, getDefaultEnvironment,
StdioClientTransport, StdioClientTransport,
} from "@modelcontextprotocol/sdk/client/stdio.js"; } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { findActualExecutable } from "spawn-rx"; import { findActualExecutable } from "spawn-rx";
export type TransportOptions = { export type TransportOptions = {
transportType: "sse" | "stdio"; transportType: "sse" | "stdio" | "http";
command?: string; command?: string;
args?: string[]; args?: string[];
url?: string; url?: string;
@ -15,11 +16,22 @@ export type TransportOptions = {
function createSSETransport(options: TransportOptions): Transport { function createSSETransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? ""); const baseUrl = new URL(options.url ?? "");
const sseUrl = new URL("/sse", baseUrl); const sseUrl = baseUrl.pathname.endsWith("/sse")
? baseUrl
: new URL("/sse", baseUrl);
return new SSEClientTransport(sseUrl); return new SSEClientTransport(sseUrl);
} }
function createHTTPTransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const mcpUrl = baseUrl.pathname.endsWith("/mcp")
? baseUrl
: new URL("/mcp", baseUrl);
return new StreamableHTTPClientTransport(mcpUrl);
}
function createStdioTransport(options: TransportOptions): Transport { function createStdioTransport(options: TransportOptions): Transport {
let args: string[] = []; let args: string[] = [];
@ -67,6 +79,10 @@ export function createTransport(options: TransportOptions): Transport {
return createSSETransport(options); return createSSETransport(options);
} }
if (transportType === "http") {
return createHTTPTransport(options);
}
throw new Error(`Unsupported transport type: ${transportType}`); throw new Error(`Unsupported transport type: ${transportType}`);
} catch (error) { } catch (error) {
throw new Error( throw new Error(

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

@ -34,6 +34,7 @@
"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",

Some files were not shown because too many files have changed in this diff Show more