Merge branch 'main' into auto_open

This commit is contained in:
KAWAKAMI Moeki
2025-05-04 19:46:52 +09:00
26 changed files with 619 additions and 101 deletions

View File

@@ -26,6 +26,10 @@ jobs:
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- name: Check linting
working-directory: ./client
run: npm run lint
- name: Run client tests - name: Run client tests
working-directory: ./client working-directory: ./client
run: npm test run: npm test
@@ -54,8 +58,6 @@ jobs:
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- run: npm run build
# TODO: Add --provenance once the repo is public # TODO: Add --provenance once the repo is public
- run: npm run publish-all - run: npm run publish-all
env: env:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-cli", "name": "@modelcontextprotocol/inspector-cli",
"version": "0.10.2", "version": "0.11.0",
"description": "CLI for the Model Context Protocol inspector", "description": "CLI for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -21,7 +21,7 @@
}, },
"devDependencies": {}, "devDependencies": {},
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
} }

View File

@@ -9,10 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist"); const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => { const server = http.createServer((request, response) => {
return handler(request, response, { const handlerOptions = {
public: distPath, public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }], rewrites: [{ source: "/**", destination: "/index.html" }],
}); headers: [
{
// Ensure index.html is never cached
source: "index.html",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, max-age=0",
},
],
},
{
// Allow long-term caching for hashed assets
source: "assets/**",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
],
};
return handler(request, response, handlerOptions);
}); });
const port = process.env.PORT || 6274; const port = process.env.PORT || 6274;

View File

@@ -1,6 +1,6 @@
module.exports = { module.exports = {
preset: "ts-jest", preset: "ts-jest",
testEnvironment: "jsdom", testEnvironment: "jest-fixed-jsdom",
moduleNameMapper: { moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1", "^@/(.*)$": "<rootDir>/src/$1",
"\\.css$": "<rootDir>/src/__mocks__/styleMock.js", "\\.css$": "<rootDir>/src/__mocks__/styleMock.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.10.2", "version": "0.11.0",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch" "test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"@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",

View File

@@ -81,9 +81,14 @@ const App = () => {
const [sseUrl, setSseUrl] = useState<string>(() => { const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
}); });
const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(() => {
return ( return (
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" (localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
); );
}); });
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug"); const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
@@ -642,6 +647,7 @@ const App = () => {
setSelectedPrompt={(prompt) => { setSelectedPrompt={(prompt) => {
clearError("prompts"); clearError("prompts");
setSelectedPrompt(prompt); setSelectedPrompt(prompt);
setPromptContent("");
}} }}
handleCompletion={handleCompletion} handleCompletion={handleCompletion}
completionsSupported={completionsSupported} completionsSupported={completionsSupported}

View File

@@ -250,7 +250,12 @@ const DynamicJsonForm = ({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
{isJsonMode && ( {isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}> <Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON Format JSON
</Button> </Button>
)} )}

View File

@@ -3,7 +3,7 @@ import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx"; import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react"; import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils"; import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps { interface JsonViewProps {

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { InspectorOAuthClientProvider } from "../lib/auth"; import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants"; import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts"; import { useToast } from "@/lib/hooks/useToast";
import { import {
generateOAuthErrorDescription, generateOAuthErrorDescription,
parseOAuthCallbackParams, parseOAuthCallbackParams,

View File

@@ -0,0 +1,167 @@
import { Button } from "@/components/ui/button";
import JsonView from "./JsonView";
import { useMemo, useState } from "react";
import {
CreateMessageResult,
CreateMessageResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { PendingRequest } from "./SamplingTab";
import DynamicJsonForm from "./DynamicJsonForm";
import { useToast } from "@/lib/hooks/useToast";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
export type SamplingRequestProps = {
request: PendingRequest;
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
const SamplingRequest = ({
onApprove,
request,
onReject,
}: SamplingRequestProps) => {
const { toast } = useToast();
const [messageResult, setMessageResult] = useState<JsonValue>({
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
const contentType = (
(messageResult as { [key: string]: JsonValue })?.content as {
[key: string]: JsonValue;
}
)?.type;
const schema = useMemo(() => {
const s: JsonSchemaType = {
type: "object",
description: "Message result",
properties: {
model: {
type: "string",
default: "stub-model",
description: "model name",
},
stopReason: {
type: "string",
default: "endTurn",
description: "Stop reason",
},
role: {
type: "string",
default: "endTurn",
description: "Role of the model",
},
content: {
type: "object",
properties: {
type: {
type: "string",
default: "text",
description: "Type of content",
},
},
},
},
};
if (contentType === "text" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
text: {
type: "string",
default: "",
description: "text content",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
text: "",
},
}));
} else if (contentType === "image" && s.properties) {
s.properties.content.properties = {
...s.properties.content.properties,
data: {
type: "string",
default: "",
description: "Base64 encoded image data",
},
mimeType: {
type: "string",
default: "",
description: "Mime type of the image",
},
};
setMessageResult((prev) => ({
...(prev as { [key: string]: JsonValue }),
content: {
type: contentType,
data: "",
mimeType: "",
},
}));
}
return s;
}, [contentType]);
const handleApprove = (id: number) => {
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
if (!validationResult.success) {
toast({
title: "Error",
description: `There was an error validating the message result: ${validationResult.error.message}`,
variant: "destructive",
});
return;
}
onApprove(id, validationResult.data);
};
return (
<div
data-testid="sampling-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<JsonView data={JSON.stringify(request.request)} />
</div>
<form className="flex-1 space-y-4">
<div className="space-y-2">
<DynamicJsonForm
schema={schema}
value={messageResult}
onChange={(newValue: JsonValue) => {
setMessageResult(newValue);
}}
/>
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button
type="button"
variant="outline"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</form>
</div>
);
};
export default SamplingRequest;

View File

@@ -1,11 +1,10 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { import {
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import JsonView from "./JsonView"; import SamplingRequest from "./SamplingRequest";
export type PendingRequest = { export type PendingRequest = {
id: number; id: number;
@@ -19,19 +18,6 @@ export type Props = {
}; };
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
const handleApprove = (id: number) => {
// For now, just return a stub response
onApprove(id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "This is a stub response.",
},
});
};
return ( return (
<TabsContent value="sampling"> <TabsContent value="sampling">
<div className="h-96"> <div className="h-96">
@@ -44,21 +30,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3> <h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4"> <SamplingRequest
<JsonView key={request.id}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded" request={request}
data={JSON.stringify(request.request)} onApprove={onApprove}
/> onReject={onReject}
/>
<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div>
))} ))}
{pendingRequests.length === 0 && ( {pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p> <p className="text-gray-500">No pending requests</p>

View File

@@ -29,7 +29,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes"; import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants"; import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/useTheme"; import useTheme from "../lib/hooks/useTheme";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
import { import {
Tooltip, Tooltip,
@@ -39,8 +39,8 @@ import {
interface SidebarProps { interface SidebarProps {
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse"; transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse") => void; setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
command: string; command: string;
setCommand: (command: string) => void; setCommand: (command: string) => void;
args: string; args: string;
@@ -117,7 +117,7 @@ const Sidebar = ({
</label> </label>
<Select <Select
value={transportType} value={transportType}
onValueChange={(value: "stdio" | "sse") => onValueChange={(value: "stdio" | "sse" | "streamable-http") =>
setTransportType(value) setTransportType(value)
} }
> >
@@ -127,6 +127,7 @@ const Sidebar = ({
<SelectContent> <SelectContent>
<SelectItem value="stdio">STDIO</SelectItem> <SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem> <SelectItem value="sse">SSE</SelectItem>
<SelectItem value="streamable-http">Streamable HTTP</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -7,7 +7,7 @@ import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook // Mock theme hook
jest.mock("../../lib/useTheme", () => ({ jest.mock("../../lib/hooks/useTheme", () => ({
__esModule: true, __esModule: true,
default: () => ["light", jest.fn()], default: () => ["light", jest.fn()],
})); }));

View File

@@ -0,0 +1,73 @@
import { render, screen, fireEvent } from "@testing-library/react";
import SamplingRequest from "../SamplingRequest";
import { PendingRequest } from "../SamplingTab";
const mockRequest: PendingRequest = {
id: 1,
request: {
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: {
type: "text",
text: "What files are in the current directory?",
},
},
],
systemPrompt: "You are a helpful file system assistant.",
includeContext: "thisServer",
maxTokens: 100,
},
},
};
describe("Form to handle sampling response", () => {
const mockOnApprove = jest.fn();
const mockOnReject = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it("should call onApprove with correct text content when Approve button is clicked", () => {
render(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);
// Click the Approve button
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
// Assert that onApprove is called with the correct arguments
expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: {
type: "text",
text: "",
},
});
});
it("should call onReject with correct request id when Reject button is clicked", () => {
render(
<SamplingRequest
request={mockRequest}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>,
);
// Click the Approve button
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
// Assert that onApprove is called with the correct arguments
expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id);
});
});

View File

@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { Tabs } from "@/components/ui/tabs";
import SamplingTab, { PendingRequest } from "../SamplingTab";
describe("Sampling tab", () => {
const mockOnApprove = jest.fn();
const mockOnReject = jest.fn();
const renderSamplingTab = (pendingRequests: PendingRequest[]) =>
render(
<Tabs defaultValue="sampling">
<SamplingTab
pendingRequests={pendingRequests}
onApprove={mockOnApprove}
onReject={mockOnReject}
/>
</Tabs>,
);
it("should render 'No pending requests' when there are no pending requests", () => {
renderSamplingTab([]);
expect(
screen.getByText(
"When the server requests LLM sampling, requests will appear here for approval.",
),
).toBeTruthy();
expect(screen.findByText("No pending requests")).toBeTruthy();
});
it("should render the correct number of requests", () => {
renderSamplingTab(
Array.from({ length: 5 }, (_, i) => ({
id: i,
request: {
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: {
type: "text",
text: "What files are in the current directory?",
},
},
],
systemPrompt: "You are a helpful file system assistant.",
includeContext: "thisServer",
maxTokens: 100,
},
},
})),
);
expect(screen.getAllByTestId("sampling-request").length).toBe(5);
});
});

View File

@@ -1,4 +1,4 @@
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/lib/hooks/useToast";
import { import {
Toast, Toast,
ToastClose, ToastClose,

View File

@@ -37,7 +37,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
})); }));
// Mock the toast hook // Mock the toast hook
jest.mock("@/hooks/use-toast", () => ({ jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({ useToast: () => ({
toast: jest.fn(), toast: jest.fn(),
}), }),

View File

@@ -3,6 +3,7 @@ import {
SSEClientTransport, SSEClientTransport,
SseError, SseError,
} from "@modelcontextprotocol/sdk/client/sse.js"; } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { import {
ClientNotification, ClientNotification,
ClientRequest, ClientRequest,
@@ -26,7 +27,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod"; import { z } from "zod";
import { ConnectionStatus } from "../constants"; import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification, StdErrNotificationSchema } from "../notificationTypes";
@@ -42,7 +43,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes"; import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse" | "streamable-http";
command: string; command: string;
args: string; args: string;
sseUrl: string; sseUrl: string;
@@ -278,15 +279,29 @@ export function useConnection({
setConnectionStatus("error-connecting-to-proxy"); setConnectionStatus("error-connecting-to-proxy");
return; return;
} }
const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); let mcpProxyServerUrl;
mcpProxyServerUrl.searchParams.append("transportType", transportType); switch (transportType) {
if (transportType === "stdio") { case "stdio":
mcpProxyServerUrl.searchParams.append("command", command); mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("args", args); mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); mcpProxyServerUrl.searchParams.append("args", args);
} else { mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
mcpProxyServerUrl.searchParams.append("url", sseUrl); break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
} }
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
try { try {
// Inject auth manually instead of using SSEClientTransport, because we're // Inject auth manually instead of using SSEClientTransport, because we're
@@ -304,14 +319,24 @@ export function useConnection({
headers[authHeaderName] = `Bearer ${token}`; headers[authHeaderName] = `Bearer ${token}`;
} }
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, { // Create appropriate transport
const transportOptions = {
eventSourceInit: { eventSourceInit: {
fetch: (url, init) => fetch(url, { ...init, headers }), fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
}, },
requestInit: { requestInit: {
headers, headers,
}, },
}); };
const clientTransport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
})
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
if (onNotification) { if (onNotification) {
[ [

View File

@@ -51,13 +51,13 @@ describe("generateDefaultValue", () => {
test("generates null for non-required primitive types", () => { test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe( expect(generateDefaultValue({ type: "string", required: false })).toBe(
null, undefined,
); );
expect(generateDefaultValue({ type: "number", required: false })).toBe( expect(generateDefaultValue({ type: "number", required: false })).toBe(
null, undefined,
); );
expect(generateDefaultValue({ type: "boolean", required: false })).toBe( expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
null, undefined,
); );
}); });

View File

@@ -13,7 +13,7 @@ export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if (!schema.required) { if (!schema.required) {
if (schema.type === "array") return []; if (schema.type === "array") return [];
if (schema.type === "object") return {}; if (schema.type === "object") return {};
return null; return undefined;
} }
switch (schema.type) { switch (schema.type) {

44
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.10.2", "version": "0.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.10.2", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"client", "client",
@@ -14,10 +14,10 @@
"cli" "cli"
], ],
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-cli": "^0.10.2", "@modelcontextprotocol/inspector-cli": "^0.11.0",
"@modelcontextprotocol/inspector-client": "^0.10.2", "@modelcontextprotocol/inspector-client": "^0.11.0",
"@modelcontextprotocol/inspector-server": "^0.10.2", "@modelcontextprotocol/inspector-server": "^0.11.0",
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"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",
@@ -32,16 +32,17 @@
"@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",
"jest-fixed-jsdom": "^0.0.9",
"prettier": "3.3.3", "prettier": "3.3.3",
"typescript": "^5.4.2" "typescript": "^5.4.2"
} }
}, },
"cli": { "cli": {
"name": "@modelcontextprotocol/inspector-cli", "name": "@modelcontextprotocol/inspector-cli",
"version": "0.10.1", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
}, },
@@ -59,10 +60,10 @@
}, },
"client": { "client": {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.10.1", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"@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",
@@ -1400,9 +1401,9 @@
"link": true "link": true
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.10.0", "version": "1.10.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz",
"integrity": "sha512-wijOavYZfSOADbVM0LA7mrQ17N4IKNdFcfezknCCsZ1Y1KstVWlkDZ5ebcxuQJmqTTxsNjBHLc7it1SV0TBiPg==", "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -5462,6 +5463,19 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/jest-fixed-jsdom": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.9.tgz",
"integrity": "sha512-KPfqh2+sn5q2B+7LZktwDcwhCpOpUSue8a1I+BcixWLOQoEVyAjAGfH+IYZGoxZsziNojoHGRTC8xRbB1wDD4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"jest-environment-jsdom": ">=28.0.0"
}
},
"node_modules/jest-get-type": { "node_modules/jest-get-type": {
"version": "29.6.3", "version": "29.6.3",
"dev": true, "dev": true,
@@ -8691,10 +8705,10 @@
}, },
"server": { "server": {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.10.1", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"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

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.10.2", "version": "0.11.0",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -35,13 +35,14 @@
"test-cli": "cd cli && npm run test", "test-cli": "cd cli && npm run test",
"prettier-fix": "prettier --write .", "prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .", "prettier-check": "prettier --check .",
"prepare": "npm run build",
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-cli": "^0.10.2", "@modelcontextprotocol/inspector-cli": "^0.11.0",
"@modelcontextprotocol/inspector-client": "^0.10.2", "@modelcontextprotocol/inspector-client": "^0.11.0",
"@modelcontextprotocol/inspector-server": "^0.10.2", "@modelcontextprotocol/inspector-server": "^0.11.0",
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"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",
@@ -53,6 +54,7 @@
"@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",
"jest-fixed-jsdom": "^0.0.9",
"prettier": "3.3.3", "prettier": "3.3.3",
"typescript": "^5.4.2" "typescript": "^5.4.2"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.10.2", "version": "0.11.0",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2" "typescript": "^5.6.2"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"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

@@ -12,13 +12,21 @@ import {
StdioClientTransport, StdioClientTransport,
getDefaultEnvironment, getDefaultEnvironment,
} from "@modelcontextprotocol/sdk/client/stdio.js"; } from "@modelcontextprotocol/sdk/client/stdio.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import express from "express"; import express from "express";
import { findActualExecutable } from "spawn-rx"; import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js"; import mcpProxy from "./mcpProxy.js";
import { randomUUID } from "node:crypto";
const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
"authorization",
"mcp-session-id",
"last-event-id",
];
const defaultEnvironment = { const defaultEnvironment = {
...getDefaultEnvironment(), ...getDefaultEnvironment(),
@@ -35,8 +43,12 @@ const { values } = parseArgs({
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use((req, res, next) => {
res.header("Access-Control-Expose-Headers", "mcp-session-id");
next();
});
let webAppTransports: SSEServerTransport[] = []; const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Transports by sessionId
const createTransport = async (req: express.Request): Promise<Transport> => { const createTransport = async (req: express.Request): Promise<Transport> => {
const query = req.query; const query = req.query;
@@ -94,6 +106,31 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
console.log("Connected to SSE transport"); console.log("Connected to SSE transport");
return transport; return transport;
} else if (transportType === "streamable-http") {
const headers: HeadersInit = {
Accept: "text/event-stream, application/json",
};
for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) {
continue;
}
const value = req.headers[key];
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
}
const transport = new StreamableHTTPClientTransport(
new URL(query.url as string),
{
requestInit: {
headers,
},
},
);
await transport.start();
console.log("Connected to Streamable HTTP transport");
return transport;
} else { } else {
console.error(`Invalid transport type: ${transportType}`); console.error(`Invalid transport type: ${transportType}`);
throw new Error("Invalid transport type specified"); throw new Error("Invalid transport type specified");
@@ -102,9 +139,96 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
let backingServerTransport: Transport | undefined; let backingServerTransport: Transport | undefined;
app.get("/sse", async (req, res) => { app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string;
console.log(`Received GET message for sessionId ${sessionId}`);
try { try {
console.log("New SSE connection"); const transport = webAppTransports.get(
sessionId,
) as StreamableHTTPServerTransport;
if (!transport) {
res.status(404).end("Session not found");
return;
} else {
await transport.handleRequest(req, res);
}
} catch (error) {
console.error("Error in /mcp route:", error);
res.status(500).json(error);
}
});
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
console.log(`Received POST message for sessionId ${sessionId}`);
if (!sessionId) {
try {
console.log("New streamable-http connection");
try {
await backingServerTransport?.close();
backingServerTransport = await createTransport(req);
} catch (error) {
if (error instanceof SseError && error.code === 401) {
console.error(
"Received 401 Unauthorized from MCP server:",
error.message,
);
res.status(401).json(error);
return;
}
throw error;
}
console.log("Connected MCP client to backing server transport");
const webAppTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: (sessionId) => {
webAppTransports.set(sessionId, webAppTransport);
console.log("Created streamable web app transport " + sessionId);
},
});
await webAppTransport.start();
mcpProxy({
transportToClient: webAppTransport,
transportToServer: backingServerTransport,
});
await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
req,
res,
req.body,
);
} catch (error) {
console.error("Error in /mcp POST route:", error);
res.status(500).json(error);
}
} else {
try {
const transport = webAppTransports.get(
sessionId,
) as StreamableHTTPServerTransport;
if (!transport) {
res.status(404).end("Transport not found for sessionId " + sessionId);
} else {
await (transport as StreamableHTTPServerTransport).handleRequest(
req,
res,
);
}
} catch (error) {
console.error("Error in /mcp route:", error);
res.status(500).json(error);
}
}
});
app.get("/stdio", async (req, res) => {
try {
console.log("New connection");
try { try {
await backingServerTransport?.close(); await backingServerTransport?.close();
@@ -125,15 +249,14 @@ app.get("/sse", async (req, res) => {
console.log("Connected MCP client to backing server transport"); console.log("Connected MCP client to backing server transport");
const webAppTransport = new SSEServerTransport("/message", res); const webAppTransport = new SSEServerTransport("/message", res);
console.log("Created web app transport"); webAppTransports.set(webAppTransport.sessionId, webAppTransport);
webAppTransports.push(webAppTransport);
console.log("Created web app transport"); console.log("Created web app transport");
await webAppTransport.start(); await webAppTransport.start();
(backingServerTransport as StdioClientTransport).stderr!.on(
if (backingServerTransport instanceof StdioClientTransport) { "data",
backingServerTransport.stderr!.on("data", (chunk) => { (chunk) => {
webAppTransport.send({ webAppTransport.send({
jsonrpc: "2.0", jsonrpc: "2.0",
method: "notifications/stderr", method: "notifications/stderr",
@@ -141,9 +264,51 @@ app.get("/sse", async (req, res) => {
content: chunk.toString(), content: chunk.toString(),
}, },
}); });
}); },
);
mcpProxy({
transportToClient: webAppTransport,
transportToServer: backingServerTransport,
});
console.log("Set up MCP proxy");
} catch (error) {
console.error("Error in /stdio route:", error);
res.status(500).json(error);
}
});
app.get("/sse", async (req, res) => {
try {
console.log(
"New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http",
);
try {
await backingServerTransport?.close();
backingServerTransport = await createTransport(req);
} catch (error) {
if (error instanceof SseError && error.code === 401) {
console.error(
"Received 401 Unauthorized from MCP server:",
error.message,
);
res.status(401).json(error);
return;
}
throw error;
} }
console.log("Connected MCP client to backing server transport");
const webAppTransport = new SSEServerTransport("/message", res);
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
console.log("Created web app transport");
await webAppTransport.start();
mcpProxy({ mcpProxy({
transportToClient: webAppTransport, transportToClient: webAppTransport,
transportToServer: backingServerTransport, transportToServer: backingServerTransport,
@@ -161,7 +326,9 @@ app.post("/message", async (req, res) => {
const sessionId = req.query.sessionId; const sessionId = req.query.sessionId;
console.log(`Received message for sessionId ${sessionId}`); console.log(`Received message for sessionId ${sessionId}`);
const transport = webAppTransports.find((t) => t.sessionId === sessionId); const transport = webAppTransports.get(
sessionId as string,
) as SSEServerTransport;
if (!transport) { if (!transport) {
res.status(404).end("Session not found"); res.status(404).end("Session not found");
return; return;