Merge branch 'modelcontextprotocol:main' into main

This commit is contained in:
Mitko Iliev
2025-04-29 12:51:22 +03:00
committed by GitHub
12 changed files with 529 additions and 65 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

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

@@ -647,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

@@ -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 "@/hooks/use-toast";
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

@@ -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

@@ -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,
@@ -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) {
[ [

28
package-lock.json generated
View File

@@ -17,7 +17,7 @@
"@modelcontextprotocol/inspector-cli": "^0.10.2", "@modelcontextprotocol/inspector-cli": "^0.10.2",
"@modelcontextprotocol/inspector-client": "^0.10.2", "@modelcontextprotocol/inspector-client": "^0.10.2",
"@modelcontextprotocol/inspector-server": "^0.10.2", "@modelcontextprotocol/inspector-server": "^0.10.2",
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",
@@ -31,13 +31,14 @@
"@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.10.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.0",
@@ -58,7 +59,7 @@
}, },
"client": { "client": {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.10.1", "version": "0.10.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.0",
@@ -1399,9 +1400,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",
@@ -5358,6 +5359,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,
@@ -8550,7 +8564,7 @@
}, },
"server": { "server": {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.10.1", "version": "0.10.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.0",

View File

@@ -41,7 +41,7 @@
"@modelcontextprotocol/inspector-cli": "^0.10.2", "@modelcontextprotocol/inspector-cli": "^0.10.2",
"@modelcontextprotocol/inspector-client": "^0.10.2", "@modelcontextprotocol/inspector-client": "^0.10.2",
"@modelcontextprotocol/inspector-server": "^0.10.2", "@modelcontextprotocol/inspector-server": "^0.10.2",
"@modelcontextprotocol/sdk": "^1.10.0", "@modelcontextprotocol/sdk": "^1.10.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",
@@ -52,6 +52,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

@@ -12,15 +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 { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 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"]; const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
"authorization",
"mcp-session-id",
"last-event-id",
];
const defaultEnvironment = { const defaultEnvironment = {
...getDefaultEnvironment(), ...getDefaultEnvironment(),
@@ -37,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;
@@ -97,7 +107,9 @@ 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") { } else if (transportType === "streamable-http") {
const headers: HeadersInit = {}; const headers: HeadersInit = {
Accept: "text/event-stream, application/json",
};
for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) { for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) { if (req.headers[key] === undefined) {
@@ -127,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();
@@ -150,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",
@@ -166,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,
@@ -186,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;