Merge branch 'main' into main

This commit is contained in:
Cliff Hall 2025-08-12 16:25:42 -04:00 • committed by GitHub
commit b4d1bc71f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 934 additions and 77 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ sdk
client/playwright-report/
client/results.json
client/test-results/
mcp.json

View file

@ -2,3 +2,4 @@ packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md
mcp.json

View file

@ -234,6 +234,78 @@ Example server configuration file:
}
```
#### Transport Types in Config Files
The inspector automatically detects the transport type from your config file. You can specify different transport types:
**STDIO (default):**
```json
{
"mcpServers": {
"my-stdio-server": {
"type": "stdio",
"command": "npx",
"args": ["@modelcontextprotocol/server-everything"]
}
}
}
```
**SSE (Server-Sent Events):**
```json
{
"mcpServers": {
"my-sse-server": {
"type": "sse",
"url": "http://localhost:3000/sse"
}
}
}
```
**Streamable HTTP:**
```json
{
"mcpServers": {
"my-http-server": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp"
}
}
}
```
#### Default Server Selection
You can launch the inspector without specifying a server name if your config has:
1. **A single server** - automatically selected:
```bash
# Automatically uses "my-server" if it's the only one
npx @modelcontextprotocol/inspector --config mcp.json
```
2. **A server named "default-server"** - automatically selected:
```json
{
"mcpServers": {
"default-server": {
"command": "npx",
"args": ["@modelcontextprotocol/server-everything"]
},
"other-server": {
"command": "node",
"args": ["other.js"]
}
}
}
```
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.2",
"version": "0.16.3",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -120,6 +120,85 @@ try {
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
// Create config files with different transport types for testing
const sseConfigPath = path.join(TEMP_DIR, "sse-config.json");
fs.writeFileSync(
sseConfigPath,
JSON.stringify(
{
mcpServers: {
"test-sse": {
type: "sse",
url: "http://localhost:3000/sse",
note: "Test SSE server",
},
},
},
null,
2,
),
);
const httpConfigPath = path.join(TEMP_DIR, "http-config.json");
fs.writeFileSync(
httpConfigPath,
JSON.stringify(
{
mcpServers: {
"test-http": {
type: "streamable-http",
url: "http://localhost:3000/mcp",
note: "Test HTTP server",
},
},
},
null,
2,
),
);
const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json");
fs.writeFileSync(
stdioConfigPath,
JSON.stringify(
{
mcpServers: {
"test-stdio": {
type: "stdio",
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
env: {
TEST_ENV: "test-value",
},
},
},
},
null,
2,
),
);
// Config without type field (backward compatibility)
const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json");
fs.writeFileSync(
legacyConfigPath,
JSON.stringify(
{
mcpServers: {
"test-legacy": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
env: {
LEGACY_ENV: "legacy-value",
},
},
},
},
null,
2,
),
);
// Function to run a basic test
async function runBasicTest(testName, ...args) {
const outputFile = path.join(
@ -649,6 +728,160 @@ async function runTests() {
"debug",
);
console.log(
`\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`,
);
// Test 25: Config with stdio transport type
await runBasicTest(
"config_stdio_type",
"--config",
stdioConfigPath,
"--server",
"test-stdio",
"--cli",
"--method",
"tools/list",
);
// Test 26: Config with SSE transport type (CLI mode) - expects connection error
await runErrorTest(
"config_sse_type_cli",
"--config",
sseConfigPath,
"--server",
"test-sse",
"--cli",
"--method",
"tools/list",
);
// Test 27: Config with streamable-http transport type (CLI mode) - expects connection error
await runErrorTest(
"config_http_type_cli",
"--config",
httpConfigPath,
"--server",
"test-http",
"--cli",
"--method",
"tools/list",
);
// Test 28: Legacy config without type field (backward compatibility)
await runBasicTest(
"config_legacy_no_type",
"--config",
legacyConfigPath,
"--server",
"test-legacy",
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`,
);
// Create config with single server for auto-selection
const singleServerConfigPath = path.join(
TEMP_DIR,
"single-server-config.json",
);
fs.writeFileSync(
singleServerConfigPath,
JSON.stringify(
{
mcpServers: {
"only-server": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
},
},
null,
2,
),
);
// Create config with default-server
const defaultServerConfigPath = path.join(
TEMP_DIR,
"default-server-config.json",
);
fs.writeFileSync(
defaultServerConfigPath,
JSON.stringify(
{
mcpServers: {
"default-server": {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
"other-server": {
command: "node",
args: ["other.js"],
},
},
},
null,
2,
),
);
// Create config with multiple servers (no default)
const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json");
fs.writeFileSync(
multiServerConfigPath,
JSON.stringify(
{
mcpServers: {
server1: {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
server2: {
command: "node",
args: ["other.js"],
},
},
},
null,
2,
),
);
// Test 29: Config with single server auto-selection
await runBasicTest(
"single_server_auto_select",
"--config",
singleServerConfigPath,
"--cli",
"--method",
"tools/list",
);
// Test 30: Config with default-server should now require explicit selection (multiple servers)
await runErrorTest(
"default_server_requires_explicit_selection",
"--config",
defaultServerConfigPath,
"--cli",
"--method",
"tools/list",
);
// Test 31: Config with multiple servers and no default (should fail)
await runErrorTest(
"multi_server_no_default",
"--config",
multiServerConfigPath,
"--cli",
"--method",
"tools/list",
);
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
@ -668,7 +901,7 @@ async function runTests() {
await new Promise((resolve) => setTimeout(resolve, 3000));
// Test 25: HTTP transport inferred from URL ending with /mcp
// Test 32: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
@ -677,7 +910,7 @@ async function runTests() {
"tools/list",
);
// Test 26: HTTP transport with explicit --transport http flag
// Test 33: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001/mcp",
@ -688,7 +921,7 @@ async function runTests() {
"tools/list",
);
// Test 27: HTTP transport with suffix and --transport http flag
// Test 34: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
@ -699,7 +932,7 @@ async function runTests() {
"tools/list",
);
// Test 28: SSE transport given to HTTP server (should fail)
// Test 35: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
@ -710,7 +943,7 @@ async function runTests() {
"tools/list",
);
// Test 29: HTTP transport without URL (should fail)
// Test 36: HTTP transport without URL (should fail)
await runErrorTest(
"http_transport_without_url",
"--transport",
@ -720,7 +953,7 @@ async function runTests() {
"tools/list",
);
// Test 30: SSE transport without URL (should fail)
// Test 37: SSE transport without URL (should fail)
await runErrorTest(
"sse_transport_without_url",
"--transport",

View file

@ -14,6 +14,8 @@ type Args = {
args: string[];
envArgs: Record<string, string>;
cli: boolean;
transport?: "stdio" | "sse" | "streamable-http";
serverUrl?: string;
};
type CliOptions = {
@ -21,13 +23,22 @@ type CliOptions = {
config?: string;
server?: string;
cli?: boolean;
transport?: string;
serverUrl?: string;
};
type ServerConfig = {
command: string;
args?: string[];
env?: Record<string, string>;
};
type ServerConfig =
| {
type: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
}
| {
type: "sse" | "streamable-http";
url: string;
note?: string;
};
function handleError(error: unknown): never {
let message: string;
@ -74,6 +85,16 @@ async function runWebClient(args: Args): Promise<void> {
startArgs.push("-e", `${key}=${value}`);
}
// Pass transport type if specified
if (args.transport) {
startArgs.push("--transport", args.transport);
}
// Pass server URL if specified
if (args.serverUrl) {
startArgs.push("--server-url", args.serverUrl);
}
// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
@ -103,7 +124,21 @@ async function runCli(args: Args): Promise<void> {
});
try {
await spawnPromise("node", [cliPath, args.command, ...args.args], {
// Build CLI arguments
const cliArgs = [cliPath];
// Add transport flag if specified
if (args.transport && args.transport !== "stdio") {
// Convert streamable-http back to http for CLI mode
const cliTransport =
args.transport === "streamable-http" ? "http" : args.transport;
cliArgs.push("--transport", cliTransport);
}
// Add command and remaining args
cliArgs.push(args.command, ...args.args);
await spawnPromise("node", cliArgs, {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
@ -190,7 +225,9 @@ function parseArgs(): Args {
)
.option("--config <path>", "config file path")
.option("--server <n>", "server name from config file")
.option("--cli", "enable CLI mode");
.option("--cli", "enable CLI mode")
.option("--transport <type>", "transport type (stdio, sse, http)")
.option("--server-url <url>", "server URL for SSE/HTTP transport");
// Parse only the arguments before --
program.parse(preArgs);
@ -201,14 +238,33 @@ function parseArgs(): Args {
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
// Validate that config and server are provided together
if (
(options.config && !options.server) ||
(!options.config && options.server)
) {
throw new Error(
"Both --config and --server must be provided together. If you specify one, you must specify the other.",
// Validate config and server options
if (!options.config && options.server) {
throw new Error("--server requires --config to be specified");
}
// If config is provided without server, try to auto-select
if (options.config && !options.server) {
const configContent = fs.readFileSync(
path.isAbsolute(options.config)
? options.config
: path.resolve(process.cwd(), options.config),
"utf8",
);
const parsedConfig = JSON.parse(configContent);
const servers = Object.keys(parsedConfig.mcpServers || {});
if (servers.length === 1) {
// Use the only server if there's just one
options.server = servers[0];
} else if (servers.length === 0) {
throw new Error("No servers found in config file");
} else {
// Multiple servers, require explicit selection
throw new Error(
`Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`,
);
}
}
// If config file is specified, load and use the options from the file. We must merge the args
@ -217,23 +273,52 @@ function parseArgs(): Args {
if (options.config && options.server) {
const config = loadConfigFile(options.config, options.server);
return {
command: config.command,
args: [...(config.args || []), ...finalArgs],
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
};
if (config.type === "stdio") {
return {
command: config.command,
args: [...(config.args || []), ...finalArgs],
envArgs: { ...(config.env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
};
} else if (config.type === "sse" || config.type === "streamable-http") {
return {
command: config.url,
args: finalArgs,
envArgs: options.e || {},
cli: options.cli || false,
transport: config.type,
serverUrl: config.url,
};
} else {
// Backwards compatibility: if no type field, assume stdio
return {
command: (config as any).command || "",
args: [...((config as any).args || []), ...finalArgs],
envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
cli: options.cli || false,
transport: "stdio",
};
}
}
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
// Map "http" shorthand to "streamable-http"
let transport = options.transport;
if (transport === "http") {
transport = "streamable-http";
}
return {
command,
args,
envArgs: options.e || {},
cli: options.cli || false,
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
serverUrl: options.serverUrl,
};
}

View file

@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
}
async function startDevServer(serverOptions) {
const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
serverOptions;
const {
SERVER_PORT,
CLIENT_PORT,
sessionToken,
envVars,
abort,
transport,
serverUrl,
} = serverOptions;
const serverCommand = "npx";
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
const isWindows = process.platform === "win32";
@ -42,6 +49,8 @@ async function startDevServer(serverOptions) {
CLIENT_PORT,
MCP_PROXY_AUTH_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
...(transport ? { MCP_TRANSPORT: transport } : {}),
...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}),
},
signal: abort.signal,
echoOutput: true,
@ -78,6 +87,8 @@ async function startProdServer(serverOptions) {
abort,
command,
mcpServerArgs,
transport,
serverUrl,
} = serverOptions;
const inspectorServerPath = resolve(
__dirname,
@ -95,6 +106,8 @@ async function startProdServer(serverOptions) {
...(mcpServerArgs && mcpServerArgs.length > 0
? [`--args=${mcpServerArgs.join(" ")}`]
: []),
...(transport ? [`--transport=${transport}`] : []),
...(serverUrl ? [`--server-url=${serverUrl}`] : []),
],
{
env: {
@ -208,6 +221,8 @@ async function main() {
let command = null;
let parsingFlags = true;
let isDev = false;
let transport = null;
let serverUrl = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@ -222,6 +237,16 @@ async function main() {
continue;
}
if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
transport = args[++i];
continue;
}
if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
serverUrl = args[++i];
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
@ -273,6 +298,8 @@ async function main() {
abort,
command,
mcpServerArgs,
transport,
serverUrl,
};
const result = isDev

View file

@ -0,0 +1,65 @@
import { test, expect } from "@playwright/test";
// These tests verify that CLI arguments correctly set URL parameters
// The CLI should parse config files and pass transport/serverUrl as URL params
test.describe("CLI Arguments @cli", () => {
test("should pass transport parameter from command line", async ({
page,
}) => {
// Simulate: npx . --transport sse --server-url http://localhost:3000/sse
await page.goto(
"http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse",
);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown shows SSE
await expect(selectTrigger).toContainText("SSE");
// Verify URL field is visible and populated
const urlInput = page.locator("#sse-url-input");
await expect(urlInput).toBeVisible();
await expect(urlInput).toHaveValue("http://localhost:3000/sse");
});
test("should pass transport parameter for streamable-http", async ({
page,
}) => {
// Simulate config with streamable-http transport
await page.goto(
"http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp",
);
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown shows Streamable HTTP
await expect(selectTrigger).toContainText("Streamable HTTP");
// Verify URL field is visible and populated
const urlInput = page.locator("#sse-url-input");
await expect(urlInput).toBeVisible();
await expect(urlInput).toHaveValue("http://localhost:3000/mcp");
});
test("should not pass transport parameter for stdio config", async ({
page,
}) => {
// Simulate stdio config (no transport param needed)
await page.goto("http://localhost:6274/");
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
await expect(selectTrigger).toBeVisible();
// Verify transport dropdown defaults to STDIO
await expect(selectTrigger).toContainText("STDIO");
// Verify command/args fields are visible
await expect(page.locator("#command-input")).toBeVisible();
await expect(page.locator("#arguments-input")).toBeVisible();
});
});

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.2",
"version": "0.16.3",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -455,6 +455,14 @@ const App = () => {
if (data.defaultArgs) {
setArgs(data.defaultArgs);
}
if (data.defaultTransport) {
setTransportType(
data.defaultTransport as "stdio" | "sse" | "streamable-http",
);
}
if (data.defaultServerUrl) {
setSseUrl(data.defaultServerUrl);
}
})
.catch((error) =>
console.error("Error fetching default environment:", error),

View file

@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
const isTypeSupported = (
type: JsonSchemaType["type"],
supportedTypes: string[],
): boolean => {
if (Array.isArray(type)) {
return type.every((t) => supportedTypes.includes(t));
}
return typeof type === "string" && supportedTypes.includes(type);
};
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (schema.type && supportedTypes.includes(schema.type)) return true;
if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true;
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every(
(prop) => prop.type && supportedTypes.includes(prop.type),
(prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
);
}
if (schema.type === "array") {
@ -181,7 +191,13 @@ const DynamicJsonForm = ({
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
let fieldType = propSchema.type;
if (Array.isArray(fieldType)) {
// Of the possible types, find the first non-null type to determine the control to render
fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0];
}
switch (fieldType) {
case "string": {
if (
propSchema.oneOf &&
@ -337,6 +353,8 @@ const DynamicJsonForm = ({
required={isRequired}
/>
);
case "null":
return null;
case "object":
if (!propSchema.properties) {
return (

View file

@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
generateDefaultValue,
isPropertyRequired,
normalizeUnionType,
} from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@ -104,7 +108,7 @@ const ToolsTab = ({
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const prop = normalizeUnionType(value as JsonSchemaType);
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
@ -148,7 +152,10 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
[key]: e.target.value,
[key]:
e.target.value === ""
? undefined
: e.target.value,
})
}
className="mt-1"

View file

@ -36,7 +36,7 @@ const mockOAuthClientInfo = {
// Mock MCP SDK functions - must be before imports
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
discoverOAuthMetadata: jest.fn(),
discoverAuthorizationServerMetadata: jest.fn(),
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
@ -46,7 +46,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
// Import the functions to get their types
import {
discoverOAuthMetadata,
discoverAuthorizationServerMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
@ -57,9 +57,10 @@ import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
// Type the mocked functions properly
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
typeof discoverOAuthMetadata
>;
const mockDiscoverAuthorizationServerMetadata =
discoverAuthorizationServerMetadata as jest.MockedFunction<
typeof discoverAuthorizationServerMetadata
>;
const mockRegisterClient = registerClient as jest.MockedFunction<
typeof registerClient
>;
@ -102,7 +103,9 @@ describe("AuthDebugger", () => {
// Suppress console errors in tests to avoid JSDOM navigation noise
jest.spyOn(console, "error").mockImplementation(() => {});
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
new Error("No protected resource metadata found"),
@ -203,7 +206,7 @@ describe("AuthDebugger", () => {
});
// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
@ -216,7 +219,7 @@ describe("AuthDebugger", () => {
});
it("should show error when quick OAuth flow fails to discover metadata", async () => {
mockDiscoverOAuthMetadata.mockRejectedValue(
mockDiscoverAuthorizationServerMetadata.mockRejectedValue(
new Error("Metadata discovery failed"),
);
@ -362,7 +365,7 @@ describe("AuthDebugger", () => {
fireEvent.click(screen.getByText("Continue"));
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
});
@ -509,7 +512,9 @@ describe("AuthDebugger", () => {
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
mockResourceMetadata,
);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
await act(async () => {
renderAuthDebugger({
@ -563,7 +568,9 @@ describe("AuthDebugger", () => {
// Mock failed metadata discovery
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
// But OAuth metadata should still work with the original URL
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
mockOAuthMetadata,
);
await act(async () => {
renderAuthDebugger({
@ -603,7 +610,7 @@ describe("AuthDebugger", () => {
});
// Verify that regular OAuth metadata discovery was still called
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("https://example.com/"),
);
});

View file

@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
it("should handle a union type of string and null", () => {
const schema: JsonSchemaType = {
type: ["string", "null"],
description: "Test string or null field",
};
render(
<DynamicJsonForm schema={schema} value={null} onChange={jest.fn()} />,
);
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
});
describe("Format Support", () => {

View file

@ -8,6 +8,7 @@ import {
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
import { generateOAuthState } from "@/utils/oauthUtils";
export const getClientInformationFromSessionStorage = async ({
serverUrl,
@ -86,6 +87,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
};
}
state(): string | Promise<string> {
return generateOAuthState();
}
async clientInformation() {
// Try to get the preregistered client information from session storage first
const preregisteredClientInformation =

View file

@ -351,6 +351,7 @@ export function useConnection({
return;
}
let lastRequest = "";
try {
// Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first.
@ -564,7 +565,9 @@ export function useConnection({
}
if (capabilities?.logging && defaultLoggingLevel) {
lastRequest = "logging/setLevel";
await client.setLoggingLevel(defaultLoggingLevel);
lastRequest = "";
}
if (onElicitationRequest) {
@ -578,6 +581,17 @@ export function useConnection({
setMcpClient(client);
setConnectionStatus("connected");
} catch (e) {
if (
lastRequest === "logging/setLevel" &&
e instanceof McpError &&
e.code === ErrorCode.MethodNotFound
) {
toast({
title: "Error",
description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`,
variant: "destructive",
});
}
console.error(e);
setConnectionStatus("error");
}

View file

@ -1,7 +1,7 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth";
import {
discoverOAuthMetadata,
discoverAuthorizationServerMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
@ -12,6 +12,7 @@ import {
OAuthMetadataSchema,
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { generateOAuthState } from "@/utils/oauthUtils";
export interface StateMachineContext {
state: AuthDebuggerState;
@ -56,7 +57,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
resourceMetadata ?? undefined,
);
const metadata = await discoverOAuthMetadata(authServerUrl);
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
@ -113,13 +114,6 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
scope = metadata.scopes_supported.join(" ");
}
// Generate a random state
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const state = Array.from(array, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl,
{
@ -127,7 +121,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
state: state,
state: generateOAuthState(),
resource: context.state.resource ?? undefined,
},
);

View file

@ -0,0 +1,86 @@
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
generateOAuthState,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
it("Returns successful: true and code when present", () => {
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
successful: true,
code: "fake-code",
});
});
it("Returns successful: false and error when error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
it("Returns optional error metadata fields when present", () => {
const search =
"?error=access_denied&" +
"error_description=User%20Denied%20Request&" +
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
expect(parseOAuthCallbackParams(search)).toEqual({
successful: false,
error: "access_denied",
error_description: "User Denied Request",
error_uri: "https://example.com/error-docs",
});
});
it("Returns error when nothing present", () => {
expect(parseOAuthCallbackParams("?")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthErrorDescription", () => {
it("When only error is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: null,
error_uri: null,
}),
).toBe("Error: invalid_request.");
});
it("When error description is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: null,
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
);
});
it("When all fields present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: "https://example.com/error-docs",
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
);
});
describe("generateOAuthState", () => {
it("Returns a string", () => {
expect(generateOAuthState()).toBeDefined();
expect(generateOAuthState()).toHaveLength(64);
});
});
});

View file

@ -1,6 +1,7 @@
import {
generateDefaultValue,
formatFieldLabel,
normalizeUnionType,
cacheToolOutputSchemas,
getToolOutputValidator,
validateToolOutput,
@ -142,6 +143,189 @@ describe("formatFieldLabel", () => {
});
});
describe("normalizeUnionType", () => {
test("normalizes anyOf with string and null to string type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional string parameter");
});
test("normalizes anyOf with boolean and null to boolean type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "boolean" }, { type: "null" }],
description: "Optional boolean parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("boolean");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional boolean parameter");
});
test("normalizes array type with string and null to string type", () => {
const schema: JsonSchemaType = {
type: ["string", "null"],
description: "Optional string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.description).toBe("Optional string parameter");
});
test("normalizes array type with boolean and null to boolean type", () => {
const schema: JsonSchemaType = {
type: ["boolean", "null"],
description: "Optional boolean parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("boolean");
expect(normalized.description).toBe("Optional boolean parameter");
});
test("normalizes anyOf with number and null to number type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Optional number parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("number");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional number parameter");
});
test("normalizes anyOf with integer and null to integer type", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "integer" }, { type: "null" }],
description: "Optional integer parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("integer");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional integer parameter");
});
test("normalizes array type with number and null to number type", () => {
const schema: JsonSchemaType = {
type: ["number", "null"],
description: "Optional number parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("number");
expect(normalized.description).toBe("Optional number parameter");
});
test("normalizes array type with integer and null to integer type", () => {
const schema: JsonSchemaType = {
type: ["integer", "null"],
description: "Optional integer parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("integer");
expect(normalized.description).toBe("Optional integer parameter");
});
test("handles anyOf with reversed order (null first)", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "null" }, { type: "string" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
});
test("leaves non-union schemas unchanged", () => {
const schema: JsonSchemaType = {
type: "string",
description: "Regular string parameter",
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves anyOf with non-matching types unchanged", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "number" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves anyOf with more than two types unchanged", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("leaves array type with non-matching types unchanged", () => {
const schema: JsonSchemaType = {
type: ["string", "number"],
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("handles schemas without type or anyOf", () => {
const schema: JsonSchemaType = {
description: "Schema without type",
};
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
});
test("preserves other properties when normalizing", () => {
const schema: JsonSchemaType = {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Optional string",
minLength: 1,
maxLength: 100,
pattern: "^[a-z]+$",
};
const normalized = normalizeUnionType(schema);
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional string");
expect(normalized.minLength).toBe(1);
expect(normalized.maxLength).toBe(100);
expect(normalized.pattern).toBe("^[a-z]+$");
});
});
describe("Output Schema Validation", () => {
const mockTools: Tool[] = [
{

View file

@ -21,7 +21,16 @@ export type JsonSchemaType = {
| "boolean"
| "array"
| "object"
| "null";
| "null"
| (
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null"
)[];
title?: string;
description?: string;
required?: string[];

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