Merge branch 'main' into alejandro.borbolla/copy-input

This commit is contained in:
Ola Hungerford 2025-09-14 21:27:07 -07:00 • committed by GitHub
commit 3075cbbf18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 815 additions and 188 deletions

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: ""
assignees: ""
---
**Inspector Version**
- [e.g. 0.16.5)
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
**Additional context**
Add any other context about the problem here.

1
.gitignore vendored
View file

@ -17,3 +17,4 @@ client/results.json
client/test-results/
client/e2e/test-results/
mcp.json
.claude/settings.local.json

View file

@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
}
```
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
**STDIO transport example:**
@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"mcpServers": {
"default-server": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
}
}
}
```
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients.
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.5",
"version": "0.16.7",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -17,13 +17,14 @@
"scripts": {
"build": "tsc",
"postbuild": "node scripts/make-executable.js",
"test": "node scripts/cli-tests.js && node scripts/cli-tool-tests.js",
"test": "node scripts/cli-tests.js && node scripts/cli-tool-tests.js && node scripts/cli-header-tests.js",
"test:cli": "node scripts/cli-tests.js",
"test:cli-tools": "node scripts/cli-tool-tests.js"
"test:cli-tools": "node scripts/cli-tool-tests.js",
"test:cli-headers": "node scripts/cli-header-tests.js"
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.3",
"@modelcontextprotocol/sdk": "^1.18.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* Integration tests for header functionality
* Tests the CLI header parsing end-to-end
*/
import { spawn } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PATH = resolve(__dirname, "..", "build", "index.js");
// ANSI colors for output
const colors = {
GREEN: "\x1b[32m",
RED: "\x1b[31m",
YELLOW: "\x1b[33m",
BLUE: "\x1b[34m",
NC: "\x1b[0m", // No Color
};
let testsPassed = 0;
let testsFailed = 0;
/**
* Run a CLI test with given arguments and check for expected behavior
*/
function runHeaderTest(
testName,
args,
expectSuccess = false,
expectedInOutput = null,
) {
return new Promise((resolve) => {
console.log(`\n${colors.BLUE}Testing: ${testName}${colors.NC}`);
console.log(
`${colors.BLUE}Command: node ${CLI_PATH} ${args.join(" ")}${colors.NC}`,
);
const child = spawn("node", [CLI_PATH, ...args], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
const output = stdout + stderr;
let passed = true;
let reason = "";
// Check exit code expectation
if (expectSuccess && code !== 0) {
passed = false;
reason = `Expected success (exit code 0) but got ${code}`;
} else if (!expectSuccess && code === 0) {
passed = false;
reason = `Expected failure (non-zero exit code) but got success`;
}
// Check expected output
if (passed && expectedInOutput && !output.includes(expectedInOutput)) {
passed = false;
reason = `Expected output to contain "${expectedInOutput}"`;
}
if (passed) {
console.log(`${colors.GREEN}PASS: ${testName}${colors.NC}`);
testsPassed++;
} else {
console.log(`${colors.RED}FAIL: ${testName}${colors.NC}`);
console.log(`${colors.RED}Reason: ${reason}${colors.NC}`);
console.log(`${colors.RED}Exit code: ${code}${colors.NC}`);
console.log(`${colors.RED}Output: ${output}${colors.NC}`);
testsFailed++;
}
resolve();
});
child.on("error", (error) => {
console.log(
`${colors.RED}ERROR: ${testName} - ${error.message}${colors.NC}`,
);
testsFailed++;
resolve();
});
});
}
async function runHeaderIntegrationTests() {
console.log(
`${colors.YELLOW}=== MCP Inspector CLI Header Integration Tests ===${colors.NC}`,
);
console.log(
`${colors.BLUE}Testing header parsing and validation${colors.NC}`,
);
// Test 1: Valid header format should parse successfully (connection will fail)
await runHeaderTest(
"Valid single header",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Authorization: Bearer token123",
],
false,
);
// Test 2: Multiple headers should parse successfully
await runHeaderTest(
"Multiple headers",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Authorization: Bearer token123",
"--header",
"X-API-Key: secret123",
],
false,
);
// Test 3: Invalid header format - no colon
await runHeaderTest(
"Invalid header format - no colon",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"InvalidHeader",
],
false,
"Invalid header format",
);
// Test 4: Invalid header format - empty name
await runHeaderTest(
"Invalid header format - empty name",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
": value",
],
false,
"Invalid header format",
);
// Test 5: Invalid header format - empty value
await runHeaderTest(
"Invalid header format - empty value",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"Header:",
],
false,
"Invalid header format",
);
// Test 6: Header with colons in value
await runHeaderTest(
"Header with colons in value",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
"X-Time: 2023:12:25:10:30:45",
],
false,
);
// Test 7: Whitespace handling
await runHeaderTest(
"Whitespace handling in headers",
[
"https://example.com",
"--method",
"tools/list",
"--transport",
"http",
"--header",
" X-Header : value with spaces ",
],
false,
);
console.log(`\n${colors.YELLOW}=== Test Results ===${colors.NC}`);
console.log(`${colors.GREEN}Tests passed: ${testsPassed}${colors.NC}`);
console.log(`${colors.RED}Tests failed: ${testsFailed}${colors.NC}`);
if (testsFailed === 0) {
console.log(
`${colors.GREEN}All header integration tests passed!${colors.NC}`,
);
process.exit(0);
} else {
console.log(
`${colors.RED}Some header integration tests failed.${colors.NC}`,
);
process.exit(1);
}
}
// Handle graceful shutdown
process.on("SIGINT", () => {
console.log(`\n${colors.YELLOW}Test interrupted by user${colors.NC}`);
process.exit(1);
});
process.on("SIGTERM", () => {
console.log(`\n${colors.YELLOW}Test terminated${colors.NC}`);
process.exit(1);
});
// Run the tests
runHeaderIntegrationTests().catch((error) => {
console.error(`${colors.RED}Test runner error: ${error.message}${colors.NC}`);
process.exit(1);
});

View file

@ -106,6 +106,10 @@ async function runWebClient(args: Args): Promise<void> {
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
signal: abort.signal,
echoOutput: true,
// pipe the stdout through here, prevents issues with buffering and
// dropping the end of console.out after 8192 chars due to node
// closing the stdout pipe before the output has finished flushing
stdio: "inherit",
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
@ -151,6 +155,10 @@ async function runCli(args: Args): Promise<void> {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
// pipe the stdout through here, prevents issues with buffering and
// dropping the end of console.out after 8192 chars due to node
// closing the stdout pipe before the output has finished flushing
stdio: "inherit",
});
} catch (e) {
if (!cancelled || process.env.DEBUG) {

View file

@ -352,8 +352,8 @@ async function main(): Promise<void> {
const args = parseArgs();
await callMethod(args);
// Let Node.js naturally exit instead of force-exiting
// process.exit(0) was causing stdout truncation
// Explicitly exit to ensure process terminates in CI
process.exit(0);
} catch (error) {
handleError(error);
}

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.5",
"version": "0.16.7",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,7 +25,7 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.3",
"@modelcontextprotocol/sdk": "^1.18.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@ -48,7 +48,6 @@
"react-simple-code-editor": "^0.14.1",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.76"
},
"devDependencies": {
@ -72,6 +71,7 @@
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"ts-jest": "^29.4.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.38.0",

View file

@ -200,6 +200,7 @@ const App = () => {
serverCapabilities,
mcpClient,
requestHistory,
clearRequestHistory,
makeRequest,
sendNotification,
handleCompletion,
@ -736,6 +737,10 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
const handleClearNotifications = () => {
setNotifications([]);
};
const sendLogLevelRequest = async (level: LoggingLevel) => {
await sendMCPRequest(
{
@ -1102,6 +1107,8 @@ const App = () => {
<HistoryAndNotifications
requestHistory={requestHistory}
serverNotifications={notifications}
onClearHistory={clearRequestHistory}
onClearNotifications={handleClearNotifications}
/>
</div>
</div>

View file

@ -49,6 +49,7 @@ jest.mock("../lib/hooks/useConnection", () => ({
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),

View file

@ -42,6 +42,7 @@ const disconnectedConnectionState = {
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),

View file

@ -187,7 +187,7 @@ const AuthDebugger = ({
JSON.stringify(currentState),
);
// Open the authorization URL automatically
window.location.href = currentState.authorizationUrl;
window.location.href = currentState.authorizationUrl.toString();
break;
}
}

View file

@ -1,13 +1,18 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import JsonView from "./JsonView";
import { Button } from "@/components/ui/button";
const HistoryAndNotifications = ({
requestHistory,
serverNotifications,
onClearHistory,
onClearNotifications,
}: {
requestHistory: Array<{ request: string; response?: string }>;
serverNotifications: ServerNotification[];
onClearHistory?: () => void;
onClearNotifications?: () => void;
}) => {
const [expandedRequests, setExpandedRequests] = useState<{
[key: number]: boolean;
@ -27,7 +32,17 @@ const HistoryAndNotifications = ({
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
<h2 className="text-lg font-semibold mb-4">History</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">History</h2>
<Button
variant="outline"
size="sm"
onClick={onClearHistory}
disabled={requestHistory.length === 0}
>
Clear
</Button>
</div>
{requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
@ -93,7 +108,17 @@ const HistoryAndNotifications = ({
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Server Notifications</h2>
<Button
variant="outline"
size="sm"
onClick={onClearNotifications}
disabled={serverNotifications.length === 0}
>
Clear
</Button>
</div>
{serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet

View file

@ -240,7 +240,7 @@ export const OAuthFlowProgress = ({
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
<div className="flex items-center gap-2">
<p className="text-xs break-all">
{authState.authorizationUrl}
{String(authState.authorizationUrl)}
</p>
<button
onClick={() => {

View file

@ -175,7 +175,9 @@ const Sidebar = ({
description:
transportType === "stdio"
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
: transportType === "streamable-http"
? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client."
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
});
setTimeout(() => {

View file

@ -482,7 +482,9 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
authorizationUrl: expect.objectContaining({
href: "https://oauth.example.com/authorize?scope=read+write",
}),
}),
);
});
@ -496,7 +498,9 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
authorizationUrl: expect.objectContaining({
href: "https://oauth.example.com/authorize?scope=read+write",
}),
}),
);
});

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { useState } from "react";
import { describe, it, expect, jest } from "@jest/globals";
import HistoryAndNotifications from "../HistoryAndNotifications";
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
@ -223,4 +224,66 @@ describe("HistoryAndNotifications", () => {
expect(screen.getByText("No history yet")).toBeTruthy();
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
it("clears request history when Clear is clicked", () => {
const Wrapper = () => {
const [history, setHistory] = useState(mockRequestHistory);
return (
<HistoryAndNotifications
requestHistory={history}
serverNotifications={[]}
onClearHistory={() => setHistory([])}
/>
);
};
render(<Wrapper />);
// Verify items are present initially
expect(screen.getByText("2. test/method2")).toBeTruthy();
expect(screen.getByText("1. test/method1")).toBeTruthy();
// Click Clear in History header (scoped by the History heading's container)
const historyHeader = screen.getByText("History");
const historyHeaderContainer = historyHeader.parentElement as HTMLElement;
const historyClearButton = within(historyHeaderContainer).getByRole(
"button",
{ name: "Clear" },
);
fireEvent.click(historyClearButton);
// History should now be empty
expect(screen.getByText("No history yet")).toBeTruthy();
});
it("clears server notifications when Clear is clicked", () => {
const Wrapper = () => {
const [notifications, setNotifications] =
useState<ServerNotification[]>(mockNotifications);
return (
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={notifications}
onClearNotifications={() => setNotifications([])}
/>
);
};
render(<Wrapper />);
// Verify items are present initially
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
expect(screen.getByText("1. notifications/message")).toBeTruthy();
// Click Clear in Server Notifications header (scoped by its heading's container)
const notifHeader = screen.getByText("Server Notifications");
const notifHeaderContainer = notifHeader.parentElement as HTMLElement;
const notifClearButton = within(notifHeaderContainer).getByRole("button", {
name: "Clear",
});
fireEvent.click(notifClearButton);
// Notifications should now be empty
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
});

View file

@ -446,6 +446,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.",
});
});
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
@ -504,6 +509,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"SSE URL has been copied. Use this URL directly in your MCP Client.",
});
});
it("should copy servers file configuration to clipboard for SSE transport", async () => {
@ -554,6 +564,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"Streamable HTTP URL has been copied. Use this URL directly in your MCP Client.",
});
});
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {

View file

@ -34,7 +34,7 @@ export interface AuthDebuggerState {
authServerUrl: URL | null;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
authorizationUrl: string | null;
authorizationUrl: URL | null;
authorizationCode: string;
latestError: Error | null;
statusMessage: StatusMessage | null;

View file

@ -167,7 +167,7 @@ export function useConnection({
// Add progress notification to `Server Notification` window in the UI
if (onNotification) {
onNotification({
method: "notification/progress",
method: "notifications/progress",
params,
});
}
@ -599,6 +599,15 @@ export function useConnection({
if (capabilities?.logging && defaultLoggingLevel) {
lastRequest = "logging/setLevel";
await client.setLoggingLevel(defaultLoggingLevel);
pushHistory(
{
method: "logging/setLevel",
params: {
level: defaultLoggingLevel,
},
},
{},
);
lastRequest = "";
}
@ -644,11 +653,16 @@ export function useConnection({
setServerCapabilities(null);
};
const clearRequestHistory = () => {
setRequestHistory([]);
};
return {
connectionStatus,
serverCapabilities,
mcpClient,
requestHistory,
clearRequestHistory,
makeRequest,
sendNotification,
handleCompletion,

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