diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9621e31..8efb677 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,6 +26,10 @@ jobs: # - run: npm ci - run: npm install --no-package-lock + - name: Check linting + working-directory: ./client + run: npm run lint + - name: Run client tests working-directory: ./client run: npm test @@ -54,8 +58,6 @@ jobs: # - run: npm ci - run: npm install --no-package-lock - - run: npm run build - # TODO: Add --provenance once the repo is public - run: npm run publish-all env: diff --git a/cli/package.json b/cli/package.json index ea74688..bebed5d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.10.2", + "version": "0.11.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/client/bin/client.js b/client/bin/client.js index d30cc70..7179e19 100755 --- a/client/bin/client.js +++ b/client/bin/client.js @@ -9,10 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const distPath = join(__dirname, "../dist"); const server = http.createServer((request, response) => { - return handler(request, response, { + const handlerOptions = { public: distPath, 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; diff --git a/client/jest.config.cjs b/client/jest.config.cjs index c360e72..b4d81cf 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,6 +1,6 @@ module.exports = { preset: "ts-jest", - testEnvironment: "jsdom", + testEnvironment: "jest-fixed-jsdom", moduleNameMapper: { "^@/(.*)$": "/src/$1", "\\.css$": "/src/__mocks__/styleMock.js", diff --git a/client/package.json b/client/package.json index 54b3dda..13e453c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.10.2", + "version": "0.11.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -23,7 +23,7 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 1de7948..7880b2a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -81,9 +81,14 @@ const App = () => { const [sseUrl, setSseUrl] = useState(() => { return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; }); - const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { + const [transportType, setTransportType] = useState< + "stdio" | "sse" | "streamable-http" + >(() => { return ( - (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" + (localStorage.getItem("lastTransportType") as + | "stdio" + | "sse" + | "streamable-http") || "stdio" ); }); const [logLevel, setLogLevel] = useState("debug"); @@ -642,6 +647,7 @@ const App = () => { setSelectedPrompt={(prompt) => { clearError("prompts"); setSelectedPrompt(prompt); + setPromptContent(""); }} handleCompletion={handleCompletion} completionsSupported={completionsSupported} diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index fa04467..6a5993c 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -250,7 +250,12 @@ const DynamicJsonForm = ({
{isJsonMode && ( - )} diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx index bd7ef64..e9ef0d2 100644 --- a/client/src/components/JsonView.tsx +++ b/client/src/components/JsonView.tsx @@ -3,7 +3,7 @@ import type { JsonValue } from "@/utils/jsonUtils"; import clsx from "clsx"; import { Copy, CheckCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; +import { useToast } from "@/lib/hooks/useToast"; import { getDataType, tryParseJson } from "@/utils/jsonUtils"; interface JsonViewProps { diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx index 6bfa8a3..ccfd6d9 100644 --- a/client/src/components/OAuthCallback.tsx +++ b/client/src/components/OAuthCallback.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { SESSION_KEYS } from "../lib/constants"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { useToast } from "@/hooks/use-toast.ts"; +import { useToast } from "@/lib/hooks/useToast"; import { generateOAuthErrorDescription, parseOAuthCallbackParams, diff --git a/client/src/components/SamplingRequest.tsx b/client/src/components/SamplingRequest.tsx new file mode 100644 index 0000000..0e6f365 --- /dev/null +++ b/client/src/components/SamplingRequest.tsx @@ -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({ + 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 ( +
+
+ +
+
+
+ { + setMessageResult(newValue); + }} + /> +
+
+ + +
+
+
+ ); +}; + +export default SamplingRequest; diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index d7d0212..c8ed9dc 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -1,11 +1,10 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; import { TabsContent } from "@/components/ui/tabs"; import { CreateMessageRequest, CreateMessageResult, } from "@modelcontextprotocol/sdk/types.js"; -import JsonView from "./JsonView"; +import SamplingRequest from "./SamplingRequest"; export type PendingRequest = { id: number; @@ -19,19 +18,6 @@ export type 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 (
@@ -44,21 +30,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {

Recent Requests

{pendingRequests.map((request) => ( -
- - -
- - -
-
+ ))} {pendingRequests.length === 0 && (

No pending requests

diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 5d3bcbd..9d2ef91 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -29,7 +29,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { InspectorConfig } from "@/lib/configurationTypes"; import { ConnectionStatus } from "@/lib/constants"; -import useTheme from "../lib/useTheme"; +import useTheme from "../lib/hooks/useTheme"; import { version } from "../../../package.json"; import { Tooltip, @@ -39,8 +39,8 @@ import { interface SidebarProps { connectionStatus: ConnectionStatus; - transportType: "stdio" | "sse"; - setTransportType: (type: "stdio" | "sse") => void; + transportType: "stdio" | "sse" | "streamable-http"; + setTransportType: (type: "stdio" | "sse" | "streamable-http") => void; command: string; setCommand: (command: string) => void; args: string; @@ -117,7 +117,7 @@ const Sidebar = ({
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index ed3bbb3..c7a74b1 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -7,7 +7,7 @@ import { InspectorConfig } from "@/lib/configurationTypes"; import { TooltipProvider } from "@/components/ui/tooltip"; // Mock theme hook -jest.mock("../../lib/useTheme", () => ({ +jest.mock("../../lib/hooks/useTheme", () => ({ __esModule: true, default: () => ["light", jest.fn()], })); diff --git a/client/src/components/__tests__/samplingRequest.test.tsx b/client/src/components/__tests__/samplingRequest.test.tsx new file mode 100644 index 0000000..80d87d9 --- /dev/null +++ b/client/src/components/__tests__/samplingRequest.test.tsx @@ -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( + , + ); + + // 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( + , + ); + + // 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); + }); +}); diff --git a/client/src/components/__tests__/samplingTab.test.tsx b/client/src/components/__tests__/samplingTab.test.tsx new file mode 100644 index 0000000..3e72121 --- /dev/null +++ b/client/src/components/__tests__/samplingTab.test.tsx @@ -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( + + + , + ); + + 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); + }); +}); diff --git a/client/src/components/ui/toaster.tsx b/client/src/components/ui/toaster.tsx index 5887f08..1c1b267 100644 --- a/client/src/components/ui/toaster.tsx +++ b/client/src/components/ui/toaster.tsx @@ -1,4 +1,4 @@ -import { useToast } from "@/hooks/use-toast"; +import { useToast } from "@/lib/hooks/useToast"; import { Toast, ToastClose, diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index e191d6c..c6700dc 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -37,7 +37,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ })); // Mock the toast hook -jest.mock("@/hooks/use-toast", () => ({ +jest.mock("@/lib/hooks/useToast", () => ({ useToast: () => ({ toast: jest.fn(), }), diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 73f2e1c..57efc63 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -3,6 +3,7 @@ import { SSEClientTransport, SseError, } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { ClientNotification, ClientRequest, @@ -26,7 +27,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { useState } from "react"; -import { useToast } from "@/hooks/use-toast"; +import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; @@ -42,7 +43,7 @@ import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; interface UseConnectionOptions { - transportType: "stdio" | "sse"; + transportType: "stdio" | "sse" | "streamable-http"; command: string; args: string; sseUrl: string; @@ -278,15 +279,29 @@ export function useConnection({ setConnectionStatus("error-connecting-to-proxy"); return; } - const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("transportType", transportType); - if (transportType === "stdio") { - mcpProxyServerUrl.searchParams.append("command", command); - mcpProxyServerUrl.searchParams.append("args", args); - mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); - } else { - mcpProxyServerUrl.searchParams.append("url", sseUrl); + let mcpProxyServerUrl; + switch (transportType) { + case "stdio": + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); + mcpProxyServerUrl.searchParams.append("command", command); + mcpProxyServerUrl.searchParams.append("args", args); + mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); + 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 { // Inject auth manually instead of using SSEClientTransport, because we're @@ -304,14 +319,24 @@ export function useConnection({ headers[authHeaderName] = `Bearer ${token}`; } - const clientTransport = new SSEClientTransport(mcpProxyServerUrl, { + // Create appropriate transport + const transportOptions = { eventSourceInit: { - fetch: (url, init) => fetch(url, { ...init, headers }), + fetch: ( + url: string | URL | globalThis.Request, + init: RequestInit | undefined, + ) => fetch(url, { ...init, headers }), }, requestInit: { headers, }, - }); + }; + const clientTransport = + transportType === "streamable-http" + ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + sessionId: undefined, + }) + : new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions); if (onNotification) { [ diff --git a/client/src/lib/useTheme.ts b/client/src/lib/hooks/useTheme.ts similarity index 100% rename from client/src/lib/useTheme.ts rename to client/src/lib/hooks/useTheme.ts diff --git a/client/src/hooks/use-toast.ts b/client/src/lib/hooks/useToast.ts similarity index 100% rename from client/src/hooks/use-toast.ts rename to client/src/lib/hooks/useToast.ts diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index 4c834fe..94e428a 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -51,13 +51,13 @@ describe("generateDefaultValue", () => { test("generates null for non-required primitive types", () => { expect(generateDefaultValue({ type: "string", required: false })).toBe( - null, + undefined, ); expect(generateDefaultValue({ type: "number", required: false })).toBe( - null, + undefined, ); expect(generateDefaultValue({ type: "boolean", required: false })).toBe( - null, + undefined, ); }); diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index ea92065..520b790 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -13,7 +13,7 @@ export function generateDefaultValue(schema: JsonSchemaType): JsonValue { if (!schema.required) { if (schema.type === "array") return []; if (schema.type === "object") return {}; - return null; + return undefined; } switch (schema.type) { diff --git a/package-lock.json b/package-lock.json index 3af104f..a8d1cfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.10.2", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.10.2", + "version": "0.11.0", "license": "MIT", "workspaces": [ "client", @@ -14,10 +14,10 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.10.2", - "@modelcontextprotocol/inspector-client": "^0.10.2", - "@modelcontextprotocol/inspector-server": "^0.10.2", - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/inspector-cli": "^0.11.0", + "@modelcontextprotocol/inspector-client": "^0.11.0", + "@modelcontextprotocol/inspector-server": "^0.11.0", + "@modelcontextprotocol/sdk": "^1.10.2", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -32,16 +32,17 @@ "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", + "jest-fixed-jsdom": "^0.0.9", "prettier": "3.3.3", "typescript": "^5.4.2" } }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -59,10 +60,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -1400,9 +1401,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.0.tgz", - "integrity": "sha512-wijOavYZfSOADbVM0LA7mrQ17N4IKNdFcfezknCCsZ1Y1KstVWlkDZ5ebcxuQJmqTTxsNjBHLc7it1SV0TBiPg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", + "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -5462,6 +5463,19 @@ "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": { "version": "29.6.3", "dev": true, @@ -8691,10 +8705,10 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.10.1", + "version": "0.11.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index 15aded7..90132b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.10.2", + "version": "0.11.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -35,13 +35,14 @@ "test-cli": "cd cli && npm run test", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", + "prepare": "npm run build", "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.10.2", - "@modelcontextprotocol/inspector-client": "^0.10.2", - "@modelcontextprotocol/inspector-server": "^0.10.2", - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/inspector-cli": "^0.11.0", + "@modelcontextprotocol/inspector-client": "^0.11.0", + "@modelcontextprotocol/inspector-server": "^0.11.0", + "@modelcontextprotocol/sdk": "^1.10.2", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -53,6 +54,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", + "jest-fixed-jsdom": "^0.0.9", "prettier": "3.3.3", "typescript": "^5.4.2" } diff --git a/server/package.json b/server/package.json index fc06019..ac887b2 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.10.2", + "version": "0.11.0", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -27,7 +27,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.10.2", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/server/src/index.ts b/server/src/index.ts index 0987d99..c967b60 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,13 +12,21 @@ import { StdioClientTransport, getDefaultEnvironment, } 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 { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; +import { randomUUID } from "node:crypto"; const SSE_HEADERS_PASSTHROUGH = ["authorization"]; +const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [ + "authorization", + "mcp-session-id", + "last-event-id", +]; const defaultEnvironment = { ...getDefaultEnvironment(), @@ -35,8 +43,12 @@ const { values } = parseArgs({ const app = express(); app.use(cors()); +app.use((req, res, next) => { + res.header("Access-Control-Expose-Headers", "mcp-session-id"); + next(); +}); -let webAppTransports: SSEServerTransport[] = []; +const webAppTransports: Map = new Map(); // Transports by sessionId const createTransport = async (req: express.Request): Promise => { const query = req.query; @@ -94,6 +106,31 @@ const createTransport = async (req: express.Request): Promise => { console.log("Connected to SSE 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 { console.error(`Invalid transport type: ${transportType}`); throw new Error("Invalid transport type specified"); @@ -102,9 +139,96 @@ const createTransport = async (req: express.Request): Promise => { 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 { - 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 { await backingServerTransport?.close(); @@ -125,15 +249,14 @@ app.get("/sse", async (req, res) => { console.log("Connected MCP client to backing server transport"); 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"); await webAppTransport.start(); - - if (backingServerTransport instanceof StdioClientTransport) { - backingServerTransport.stderr!.on("data", (chunk) => { + (backingServerTransport as StdioClientTransport).stderr!.on( + "data", + (chunk) => { webAppTransport.send({ jsonrpc: "2.0", method: "notifications/stderr", @@ -141,9 +264,51 @@ app.get("/sse", async (req, res) => { 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({ transportToClient: webAppTransport, transportToServer: backingServerTransport, @@ -161,7 +326,9 @@ app.post("/message", async (req, res) => { const sessionId = req.query.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) { res.status(404).end("Session not found"); return;