Merge branch 'main' into fix/optional-arrays-issue-771
This commit is contained in:
commit
d5a877a444
16 changed files with 343 additions and 178 deletions
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Inspector Version**
|
||||
|
||||
- [e.g. 0.16.5)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
26
README.md
26
README.md
|
|
@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
|||
}
|
||||
```
|
||||
|
||||
**Streamable HTTP transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||
}
|
||||
```
|
||||
|
||||
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
|
||||
|
||||
**STDIO transport example:**
|
||||
|
|
@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
|
|||
}
|
||||
```
|
||||
|
||||
**Streamable HTTP transport example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"default-server": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
|
||||
|
||||
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
|
||||
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients.
|
||||
|
||||
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ const AuthDebugger = ({
|
|||
JSON.stringify(currentState),
|
||||
);
|
||||
// Open the authorization URL automatically
|
||||
window.location.href = currentState.authorizationUrl;
|
||||
window.location.href = currentState.authorizationUrl.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export const OAuthFlowProgress = ({
|
|||
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs break-all">
|
||||
{authState.authorizationUrl}
|
||||
{String(authState.authorizationUrl)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,9 @@ const Sidebar = ({
|
|||
description:
|
||||
transportType === "stdio"
|
||||
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
: transportType === "streamable-http"
|
||||
? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client."
|
||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -482,7 +482,9 @@ describe("AuthDebugger", () => {
|
|||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
authorizationUrl: expect.objectContaining({
|
||||
href: "https://oauth.example.com/authorize?scope=read+write",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -496,7 +498,9 @@ describe("AuthDebugger", () => {
|
|||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
authorizationUrl: expect.objectContaining({
|
||||
href: "https://oauth.example.com/authorize?scope=read+write",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -446,6 +446,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
||||
|
|
@ -504,6 +509,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
||||
|
|
@ -554,6 +564,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"Streamable HTTP URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export interface AuthDebuggerState {
|
|||
authServerUrl: URL | null;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
authorizationUrl: URL | null;
|
||||
authorizationCode: string;
|
||||
latestError: Error | null;
|
||||
statusMessage: StatusMessage | null;
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
|
||||
context.provider.saveCodeVerifier(codeVerifier);
|
||||
context.updateState({
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
authorizationUrl: authorizationUrl,
|
||||
oauthStep: "authorization_code",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* @param url - The URL string to validate
|
||||
* @throws Error if the URL has an unsafe protocol
|
||||
*/
|
||||
export function validateRedirectUrl(url: string): void {
|
||||
export function validateRedirectUrl(url: string | URL): void {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
|
|
|
|||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"client",
|
||||
|
|
@ -14,10 +14,10 @@
|
|||
"cli"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-cli": "^0.16.6",
|
||||
"@modelcontextprotocol/inspector-client": "^0.16.6",
|
||||
"@modelcontextprotocol/inspector-server": "^0.16.6",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/inspector-cli": "^0.16.7",
|
||||
"@modelcontextprotocol/inspector-client": "^0.16.7",
|
||||
"@modelcontextprotocol/inspector-server": "^0.16.7",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"open": "^10.2.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
|
@ -46,10 +46,10 @@
|
|||
},
|
||||
"cli": {
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
},
|
||||
|
|
@ -69,10 +69,10 @@
|
|||
},
|
||||
"client": {
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
|
|
@ -2011,9 +2011,9 @@
|
|||
"link": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz",
|
||||
"integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.18.0.tgz",
|
||||
"integrity": "sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
|
|
@ -11746,10 +11746,10 @@
|
|||
},
|
||||
"server": {
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"description": "Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -47,10 +47,10 @@
|
|||
"check-version": "node scripts/check-version-consistency.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/inspector-cli": "^0.16.6",
|
||||
"@modelcontextprotocol/inspector-client": "^0.16.6",
|
||||
"@modelcontextprotocol/inspector-server": "^0.16.6",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@modelcontextprotocol/inspector-cli": "^0.16.7",
|
||||
"@modelcontextprotocol/inspector-client": "^0.16.7",
|
||||
"@modelcontextprotocol/inspector-server": "^0.16.7",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"open": "^10.2.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-server",
|
||||
"version": "0.16.6",
|
||||
"version": "0.16.7",
|
||||
"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.17.5",
|
||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0",
|
||||
|
|
|
|||
|
|
@ -22,12 +22,6 @@ import mcpProxy from "./mcpProxy.js";
|
|||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
|
||||
"authorization",
|
||||
"mcp-session-id",
|
||||
"last-event-id",
|
||||
];
|
||||
|
||||
const defaultEnvironment = {
|
||||
...getDefaultEnvironment(),
|
||||
|
|
@ -46,43 +40,84 @@ const { values } = parseArgs({
|
|||
});
|
||||
|
||||
// Function to get HTTP headers.
|
||||
// Supports only "sse" and "streamable-http" transport types.
|
||||
const getHttpHeaders = (
|
||||
req: express.Request,
|
||||
transportType: string,
|
||||
): HeadersInit => {
|
||||
const headers: HeadersInit = {
|
||||
Accept:
|
||||
transportType === "sse"
|
||||
? "text/event-stream"
|
||||
: "text/event-stream, application/json",
|
||||
};
|
||||
const defaultHeaders =
|
||||
transportType === "sse"
|
||||
? SSE_HEADERS_PASSTHROUGH
|
||||
: STREAMABLE_HTTP_HEADERS_PASSTHROUGH;
|
||||
const getHttpHeaders = (req: express.Request): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const key of defaultHeaders) {
|
||||
if (req.headers[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
// Iterate over all headers in the request
|
||||
for (const key in req.headers) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
const value = req.headers[key];
|
||||
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||
}
|
||||
// Check if the header is one we want to forward
|
||||
if (
|
||||
lowerKey.startsWith("mcp-") ||
|
||||
lowerKey === "authorization" ||
|
||||
lowerKey === "last-event-id"
|
||||
) {
|
||||
// Exclude the proxy's own authentication header
|
||||
if (lowerKey !== "x-mcp-proxy-auth") {
|
||||
const value = req.headers[key];
|
||||
|
||||
// If the header "x-custom-auth-header" is present, use its value as the custom header name.
|
||||
if (req.headers["x-custom-auth-header"] !== undefined) {
|
||||
const customHeaderName = req.headers["x-custom-auth-header"] as string;
|
||||
const lowerCaseHeaderName = customHeaderName.toLowerCase();
|
||||
if (req.headers[lowerCaseHeaderName] !== undefined) {
|
||||
const value = req.headers[lowerCaseHeaderName];
|
||||
headers[customHeaderName] = value as string;
|
||||
if (typeof value === "string") {
|
||||
// If the value is a string, use it directly
|
||||
headers[key] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
// If the value is an array, use the last element
|
||||
const lastValue = value.at(-1);
|
||||
if (lastValue !== undefined) {
|
||||
headers[key] = lastValue;
|
||||
}
|
||||
}
|
||||
// If value is undefined, it's skipped, which is correct.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the custom auth header separately. We expect `x-custom-auth-header`
|
||||
// to be a string containing the name of the actual authentication header.
|
||||
const customAuthHeaderName = req.headers["x-custom-auth-header"];
|
||||
if (typeof customAuthHeaderName === "string") {
|
||||
const lowerCaseHeaderName = customAuthHeaderName.toLowerCase();
|
||||
const value = req.headers[lowerCaseHeaderName];
|
||||
|
||||
if (typeof value === "string") {
|
||||
headers[customAuthHeaderName] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
// If the actual auth header was sent multiple times, use the last value.
|
||||
const lastValue = value.at(-1);
|
||||
if (lastValue !== undefined) {
|
||||
headers[customAuthHeaderName] = lastValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a headers object in-place, preserving the original Accept header.
|
||||
* This is necessary to ensure that transports holding a reference to the headers
|
||||
* object see the updates.
|
||||
* @param currentHeaders The headers object to update.
|
||||
* @param newHeaders The new headers to apply.
|
||||
*/
|
||||
const updateHeadersInPlace = (
|
||||
currentHeaders: Record<string, string>,
|
||||
newHeaders: Record<string, string>,
|
||||
) => {
|
||||
// Preserve the Accept header, which is set at transport creation and
|
||||
// is not present in subsequent client requests.
|
||||
const accept = currentHeaders["Accept"];
|
||||
|
||||
// Clear the old headers and apply the new ones.
|
||||
Object.keys(currentHeaders).forEach((key) => delete currentHeaders[key]);
|
||||
Object.assign(currentHeaders, newHeaders);
|
||||
|
||||
// Restore the Accept header.
|
||||
if (accept) {
|
||||
currentHeaders["Accept"] = accept;
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use((req, res, next) => {
|
||||
|
|
@ -92,6 +127,7 @@ app.use((req, res, next) => {
|
|||
|
||||
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId
|
||||
const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // Server Transports by web app sessionId
|
||||
const sessionHeaderHolders: Map<string, { headers: HeadersInit }> = new Map(); // For dynamic header updates
|
||||
|
||||
// Use provided token from environment or generate a new one
|
||||
const sessionToken =
|
||||
|
|
@ -174,7 +210,38 @@ const authMiddleware = (
|
|||
next();
|
||||
};
|
||||
|
||||
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||
/**
|
||||
* Creates a `fetch` function that merges dynamic session headers with the
|
||||
* headers from the actual request, ensuring that request-specific headers like
|
||||
* `Content-Type` are preserved.
|
||||
*/
|
||||
const createCustomFetch = (headerHolder: { headers: HeadersInit }) => {
|
||||
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
// Determine the headers from the original request/init.
|
||||
// The SDK may pass a Request object or a URL and an init object.
|
||||
const originalHeaders =
|
||||
input instanceof Request ? input.headers : init?.headers;
|
||||
|
||||
// Start with our dynamic session headers.
|
||||
const finalHeaders = new Headers(headerHolder.headers);
|
||||
|
||||
// Merge the SDK's request-specific headers, letting them overwrite.
|
||||
// This is crucial for preserving Content-Type on POST requests.
|
||||
new Headers(originalHeaders).forEach((value, key) => {
|
||||
finalHeaders.set(key, value);
|
||||
});
|
||||
|
||||
// This works for both `fetch(url, init)` and `fetch(request)` style calls.
|
||||
return fetch(input, { ...init, headers: finalHeaders });
|
||||
};
|
||||
};
|
||||
|
||||
const createTransport = async (
|
||||
req: express.Request,
|
||||
): Promise<{
|
||||
transport: Transport;
|
||||
headerHolder?: { headers: HeadersInit };
|
||||
}> => {
|
||||
const query = req.query;
|
||||
console.log("Query parameters:", JSON.stringify(query));
|
||||
|
||||
|
|
@ -198,11 +265,13 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
|||
});
|
||||
|
||||
await transport.start();
|
||||
return transport;
|
||||
return { transport };
|
||||
} else if (transportType === "sse") {
|
||||
const url = query.url as string;
|
||||
|
||||
const headers = getHttpHeaders(req, transportType);
|
||||
const headers = getHttpHeaders(req);
|
||||
headers["Accept"] = "text/event-stream";
|
||||
const headerHolder = { headers };
|
||||
|
||||
console.log(
|
||||
`SSE transport: url=${url}, headers=${JSON.stringify(headers)}`,
|
||||
|
|
@ -210,27 +279,28 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
|||
|
||||
const transport = new SSEClientTransport(new URL(url), {
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => fetch(url, { ...init, headers }),
|
||||
fetch: createCustomFetch(headerHolder),
|
||||
},
|
||||
requestInit: {
|
||||
headers,
|
||||
headers: headerHolder.headers,
|
||||
},
|
||||
});
|
||||
await transport.start();
|
||||
return transport;
|
||||
return { transport, headerHolder };
|
||||
} else if (transportType === "streamable-http") {
|
||||
const headers = getHttpHeaders(req, transportType);
|
||||
const headers = getHttpHeaders(req);
|
||||
headers["Accept"] = "text/event-stream, application/json";
|
||||
const headerHolder = { headers };
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(query.url as string),
|
||||
{
|
||||
requestInit: {
|
||||
headers,
|
||||
},
|
||||
// Pass a custom fetch to inject the latest headers on each request
|
||||
fetch: createCustomFetch(headerHolder),
|
||||
},
|
||||
);
|
||||
await transport.start();
|
||||
return transport;
|
||||
return { transport, headerHolder };
|
||||
} else {
|
||||
console.error(`Invalid transport type: ${transportType}`);
|
||||
throw new Error("Invalid transport type specified");
|
||||
|
|
@ -244,6 +314,15 @@ app.get(
|
|||
async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string;
|
||||
console.log(`Received GET message for sessionId ${sessionId}`);
|
||||
|
||||
const headerHolder = sessionHeaderHolders.get(sessionId);
|
||||
if (headerHolder) {
|
||||
updateHeadersInPlace(
|
||||
headerHolder.headers as Record<string, string>,
|
||||
getHttpHeaders(req),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const transport = webAppTransports.get(
|
||||
sessionId,
|
||||
|
|
@ -267,34 +346,54 @@ app.post(
|
|||
authMiddleware,
|
||||
async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
let serverTransport: Transport | undefined;
|
||||
if (!sessionId) {
|
||||
|
||||
if (sessionId) {
|
||||
console.log(`Received POST message for sessionId ${sessionId}`);
|
||||
const headerHolder = sessionHeaderHolders.get(sessionId);
|
||||
if (headerHolder) {
|
||||
updateHeadersInPlace(
|
||||
headerHolder.headers as Record<string, string>,
|
||||
getHttpHeaders(req),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("New StreamableHttp connection request");
|
||||
try {
|
||||
serverTransport = 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;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Created StreamableHttp server transport");
|
||||
} catch (error) {
|
||||
console.error("Error in /mcp route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
} else {
|
||||
console.log("New StreamableHttp connection request");
|
||||
try {
|
||||
const { transport: serverTransport, headerHolder } =
|
||||
await createTransport(req);
|
||||
|
||||
const webAppTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: (sessionId) => {
|
||||
webAppTransports.set(sessionId, webAppTransport);
|
||||
serverTransports.set(sessionId, serverTransport!);
|
||||
serverTransports.set(sessionId, serverTransport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
if (headerHolder) {
|
||||
sessionHeaderHolders.set(sessionId, headerHolder);
|
||||
}
|
||||
console.log("Client <-> Proxy sessionId: " + sessionId);
|
||||
},
|
||||
onsessionclosed: (sessionId) => {
|
||||
webAppTransports.delete(sessionId);
|
||||
serverTransports.delete(sessionId);
|
||||
sessionHeaderHolders.delete(sessionId);
|
||||
},
|
||||
});
|
||||
console.log("Created StreamableHttp client transport");
|
||||
|
||||
|
|
@ -311,25 +410,15 @@ app.post(
|
|||
req.body,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in /mcp POST route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
} else {
|
||||
console.log(`Received POST message for sessionId ${sessionId}`);
|
||||
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,
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server:",
|
||||
error.message,
|
||||
);
|
||||
res.status(401).json(error);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in /mcp route:", error);
|
||||
console.error("Error in /mcp POST route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -343,20 +432,18 @@ app.delete(
|
|||
async (req, res) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
console.log(`Received DELETE message for sessionId ${sessionId}`);
|
||||
let serverTransport: Transport | undefined;
|
||||
if (sessionId) {
|
||||
try {
|
||||
serverTransport = serverTransports.get(
|
||||
const serverTransport = serverTransports.get(
|
||||
sessionId,
|
||||
) as StreamableHTTPClientTransport;
|
||||
if (!serverTransport) {
|
||||
res.status(404).end("Transport not found for sessionId " + sessionId);
|
||||
} else {
|
||||
await (
|
||||
serverTransport as StreamableHTTPClientTransport
|
||||
).terminateSession();
|
||||
await serverTransport.terminateSession();
|
||||
webAppTransports.delete(sessionId);
|
||||
serverTransports.delete(sessionId);
|
||||
sessionHeaderHolders.delete(sessionId);
|
||||
console.log(`Transports removed for sessionId ${sessionId}`);
|
||||
}
|
||||
res.status(200).end();
|
||||
|
|
@ -375,20 +462,7 @@ app.get(
|
|||
async (req, res) => {
|
||||
try {
|
||||
console.log("New STDIO connection request");
|
||||
let serverTransport: Transport | undefined;
|
||||
try {
|
||||
serverTransport = await createTransport(req);
|
||||
} catch (error) {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
const { transport: serverTransport } = await createTransport(req);
|
||||
|
||||
const proxyFullAddress = (req.query.proxyFullAddress as string) || "";
|
||||
const prefix = proxyFullAddress || "";
|
||||
|
|
@ -422,6 +496,7 @@ app.get(
|
|||
serverTransport.close();
|
||||
webAppTransports.delete(webAppTransport.sessionId);
|
||||
serverTransports.delete(webAppTransport.sessionId);
|
||||
sessionHeaderHolders.delete(webAppTransport.sessionId);
|
||||
console.error(message);
|
||||
} else {
|
||||
// Inspect message and attempt to assign a RFC 5424 Syslog Protocol level
|
||||
|
|
@ -475,6 +550,13 @@ app.get(
|
|||
transportToServer: serverTransport,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
return;
|
||||
}
|
||||
console.error("Error in /stdio route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
|
|
@ -490,50 +572,46 @@ app.get(
|
|||
console.log(
|
||||
"New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp",
|
||||
);
|
||||
let serverTransport: Transport | undefined;
|
||||
try {
|
||||
serverTransport = await createTransport(req);
|
||||
} catch (error) {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
return;
|
||||
} else if (error instanceof SseError && error.code === 404) {
|
||||
console.error(
|
||||
"Received 404 not found from MCP server. Does the MCP server support SSE?",
|
||||
);
|
||||
res.status(404).json(error);
|
||||
return;
|
||||
} else if (JSON.stringify(error).includes("ECONNREFUSED")) {
|
||||
console.error("Connection refused. Is the MCP server running?");
|
||||
res.status(500).json(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
const { transport: serverTransport, headerHolder } =
|
||||
await createTransport(req);
|
||||
|
||||
const proxyFullAddress = (req.query.proxyFullAddress as string) || "";
|
||||
const prefix = proxyFullAddress || "";
|
||||
const endpoint = `${prefix}/message`;
|
||||
|
||||
const webAppTransport = new SSEServerTransport(endpoint, res);
|
||||
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||
console.log("Created client transport");
|
||||
|
||||
serverTransports.set(webAppTransport.sessionId, serverTransport!); // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
if (headerHolder) {
|
||||
sessionHeaderHolders.set(webAppTransport.sessionId, headerHolder);
|
||||
}
|
||||
console.log("Created server transport");
|
||||
|
||||
if (serverTransport) {
|
||||
const proxyFullAddress = (req.query.proxyFullAddress as string) || "";
|
||||
const prefix = proxyFullAddress || "";
|
||||
const endpoint = `${prefix}/message`;
|
||||
await webAppTransport.start();
|
||||
|
||||
const webAppTransport = new SSEServerTransport(endpoint, res);
|
||||
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||
console.log("Created client transport");
|
||||
|
||||
serverTransports.set(webAppTransport.sessionId, serverTransport!);
|
||||
console.log("Created server transport");
|
||||
|
||||
await webAppTransport.start();
|
||||
|
||||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: serverTransport,
|
||||
});
|
||||
}
|
||||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: serverTransport,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof SseError && error.code === 401) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
return;
|
||||
} else if (error instanceof SseError && error.code === 404) {
|
||||
console.error(
|
||||
"Received 404 not found from MCP server. Does the MCP server support SSE?",
|
||||
);
|
||||
res.status(404).json(error);
|
||||
return;
|
||||
} else if (JSON.stringify(error).includes("ECONNREFUSED")) {
|
||||
console.error("Connection refused. Is the MCP server running?");
|
||||
res.status(500).json(error);
|
||||
}
|
||||
console.error("Error in /sse route:", error);
|
||||
res.status(500).json(error);
|
||||
}
|
||||
|
|
@ -546,12 +624,18 @@ app.post(
|
|||
authMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.query.sessionId;
|
||||
const sessionId = req.query.sessionId as string;
|
||||
console.log(`Received POST message for sessionId ${sessionId}`);
|
||||
|
||||
const transport = webAppTransports.get(
|
||||
sessionId as string,
|
||||
) as SSEServerTransport;
|
||||
const headerHolder = sessionHeaderHolders.get(sessionId);
|
||||
if (headerHolder) {
|
||||
updateHeadersInPlace(
|
||||
headerHolder.headers as Record<string, string>,
|
||||
getHttpHeaders(req),
|
||||
);
|
||||
}
|
||||
|
||||
const transport = webAppTransports.get(sessionId) as SSEServerTransport;
|
||||
if (!transport) {
|
||||
res.status(404).end("Session not found");
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue