Fix for 401 errors not being sent back through the proxy (and relying on error string text matching to detect an OAuth 401)
This commit is contained in:
parent
c344ffb87c
commit
6e796ac744
2 changed files with 290 additions and 38 deletions
|
|
@ -27,7 +27,7 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { findActualExecutable } from "spawn-rx";
|
||||
import mcpProxy from "./mcpProxy.js";
|
||||
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
|
@ -72,6 +72,29 @@ const is401Error = (error: unknown): boolean => {
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefer forwarding the upstream MCP 401 (WWW-Authenticate + body) so the browser
|
||||
* matches direct-mode OAuth behavior. Falls back to JSON-encoding `error` if unknown.
|
||||
*/
|
||||
const sendProxiedUnauthorized = (
|
||||
res: express.Response,
|
||||
error: unknown,
|
||||
headerHolder?: ProxyHeaderHolder,
|
||||
) => {
|
||||
const captured = headerHolder?.lastUpstream401;
|
||||
if (captured && headerHolder) {
|
||||
if (captured.wwwAuthenticate) {
|
||||
res.setHeader("WWW-Authenticate", captured.wwwAuthenticate);
|
||||
}
|
||||
res.status(401);
|
||||
res.setHeader("Content-Type", captured.contentType);
|
||||
res.send(captured.body);
|
||||
delete headerHolder.lastUpstream401;
|
||||
return;
|
||||
}
|
||||
res.status(401).json(error);
|
||||
};
|
||||
|
||||
// Function to get HTTP headers.
|
||||
const getHttpHeaders = (req: express.Request): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
|
|
@ -175,13 +198,16 @@ const updateHeadersInPlace = (
|
|||
const app = express();
|
||||
app.use(cors());
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Expose-Headers", "mcp-session-id");
|
||||
res.header(
|
||||
"Access-Control-Expose-Headers",
|
||||
"mcp-session-id, WWW-Authenticate",
|
||||
);
|
||||
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
|
||||
const sessionHeaderHolders: Map<string, ProxyHeaderHolder> = new Map(); // For dynamic header updates
|
||||
|
||||
// Use provided token from environment or generate a new one
|
||||
const sessionToken =
|
||||
|
|
@ -303,7 +329,7 @@ const createWebReadableStream = (nodeStream: any): ReadableStream => {
|
|||
* `Content-Type` are preserved. For SSE requests, it also converts Node.js
|
||||
* streams to web-compatible streams.
|
||||
*/
|
||||
const createCustomFetch = (headerHolder: { headers: HeadersInit }) => {
|
||||
const createCustomFetch = (headerHolder: ProxyHeaderHolder) => {
|
||||
return async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
|
|
@ -334,6 +360,28 @@ const createCustomFetch = (headerHolder: { headers: HeadersInit }) => {
|
|||
{ ...init, headers: headersObject } as any,
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
const wwwAuthenticate =
|
||||
response.headers.get("www-authenticate") ?? undefined;
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "application/json";
|
||||
const body = await response.text();
|
||||
headerHolder.lastUpstream401 = {
|
||||
wwwAuthenticate,
|
||||
body,
|
||||
contentType,
|
||||
};
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 401,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
}) as Response;
|
||||
}
|
||||
|
||||
// Check if this is an SSE request by looking at the Accept header
|
||||
const acceptHeader = finalHeaders.get("Accept");
|
||||
const isSSE = acceptHeader?.includes("text/event-stream");
|
||||
|
|
@ -366,7 +414,7 @@ const createTransport = async (
|
|||
req: express.Request,
|
||||
): Promise<{
|
||||
transport: Transport;
|
||||
headerHolder?: { headers: HeadersInit };
|
||||
headerHolder?: ProxyHeaderHolder;
|
||||
}> => {
|
||||
const query = req.query;
|
||||
console.log("Query parameters:", JSON.stringify(query));
|
||||
|
|
@ -397,7 +445,7 @@ const createTransport = async (
|
|||
|
||||
const headers = getHttpHeaders(req);
|
||||
headers["Accept"] = "text/event-stream";
|
||||
const headerHolder = { headers };
|
||||
const headerHolder: ProxyHeaderHolder = { headers };
|
||||
|
||||
console.log(
|
||||
`SSE transport: url=${url}, headers=${JSON.stringify(headers)}`,
|
||||
|
|
@ -416,7 +464,7 @@ const createTransport = async (
|
|||
} else if (transportType === "streamable-http") {
|
||||
const headers = getHttpHeaders(req);
|
||||
headers["Accept"] = "text/event-stream, application/json";
|
||||
const headerHolder = { headers };
|
||||
const headerHolder: ProxyHeaderHolder = { headers };
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(query.url as string),
|
||||
|
|
@ -501,9 +549,11 @@ app.post(
|
|||
}
|
||||
} else {
|
||||
console.log("New StreamableHttp connection request");
|
||||
let streamableHeaderHolder: ProxyHeaderHolder | undefined;
|
||||
try {
|
||||
const { transport: serverTransport, headerHolder } =
|
||||
await createTransport(req);
|
||||
streamableHeaderHolder = headerHolder;
|
||||
|
||||
const webAppTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
|
|
@ -528,6 +578,7 @@ app.post(
|
|||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: serverTransport,
|
||||
headerHolder,
|
||||
});
|
||||
|
||||
await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
|
||||
|
|
@ -541,7 +592,7 @@ app.post(
|
|||
"Received 401 Unauthorized from MCP server:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
res.status(401).json(error);
|
||||
sendProxiedUnauthorized(res, error, streamableHeaderHolder);
|
||||
return;
|
||||
}
|
||||
console.error("Error in /mcp POST route:", error);
|
||||
|
|
@ -681,7 +732,7 @@ app.get(
|
|||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
sendProxiedUnauthorized(res, error, undefined);
|
||||
return;
|
||||
}
|
||||
console.error("Error in /stdio route:", error);
|
||||
|
|
@ -695,12 +746,14 @@ app.get(
|
|||
originValidationMiddleware,
|
||||
authMiddleware,
|
||||
async (req, res) => {
|
||||
let sseHeaderHolder: ProxyHeaderHolder | undefined;
|
||||
try {
|
||||
console.log(
|
||||
"New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp",
|
||||
);
|
||||
const { transport: serverTransport, headerHolder } =
|
||||
await createTransport(req);
|
||||
sseHeaderHolder = headerHolder;
|
||||
|
||||
const proxyFullAddress = (req.query.proxyFullAddress as string) || "";
|
||||
const prefix = proxyFullAddress || "";
|
||||
|
|
@ -721,13 +774,14 @@ app.get(
|
|||
mcpProxy({
|
||||
transportToClient: webAppTransport,
|
||||
transportToServer: serverTransport,
|
||||
headerHolder,
|
||||
});
|
||||
} catch (error) {
|
||||
if (is401Error(error)) {
|
||||
console.error(
|
||||
"Received 401 Unauthorized from MCP server. Authentication failure.",
|
||||
);
|
||||
res.status(401).json(error);
|
||||
sendProxiedUnauthorized(res, error, sseHeaderHolder);
|
||||
return;
|
||||
} else if (error instanceof SseError && error.code === 404) {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
import { SseError } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
/**
|
||||
* JSON-RPC error code for failed proxy → upstream transport sends.
|
||||
*
|
||||
* JSON-RPC 2.0 reserves **-32000 .. -32099** for implementation-defined *server* errors.
|
||||
* MCP uses that band for protocol/SDK errors (see `@modelcontextprotocol/sdk` `ErrorCode`, e.g.
|
||||
* `-32000` connection closed, `-32001` request timeout, `-32042` URL elicitation; other codes in
|
||||
* the band are assigned over time — e.g. **-32002** is used for **ResourceNotFound** in parts of
|
||||
* the MCP ecosystem). We use **-32099** so this inspector-only bridge error stays clear of those
|
||||
* registered meanings; the client still keys off `error.data` shape, not the number alone.
|
||||
*/
|
||||
export const MCP_PROXY_TRANSPORT_ERROR_CODE = -32099;
|
||||
|
||||
/** Session header bag used with `createCustomFetch`; may hold last upstream 401 snapshot. */
|
||||
export type ProxyHeaderHolder = {
|
||||
headers: HeadersInit;
|
||||
lastUpstream401?: {
|
||||
wwwAuthenticate?: string;
|
||||
body: string;
|
||||
contentType: string;
|
||||
};
|
||||
};
|
||||
|
||||
function onClientError(error: Error) {
|
||||
console.error("Error from inspector client:", error);
|
||||
}
|
||||
|
|
@ -15,12 +39,49 @@ function onServerError(error: Error) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Exported for unit tests; used by the proxy when forwarding transport failures. */
|
||||
export function serializeProxyTransportError(
|
||||
error: Error,
|
||||
headerHolder?: ProxyHeaderHolder,
|
||||
): Record<string, unknown> {
|
||||
const data: Record<string, unknown> = {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
};
|
||||
if (error.cause !== undefined) {
|
||||
data.cause =
|
||||
error.cause instanceof Error ? error.cause.message : String(error.cause);
|
||||
}
|
||||
const attachHttpStatusIfValid = (code: number) => {
|
||||
if (Number.isInteger(code) && code >= 100 && code <= 599) {
|
||||
data.httpStatus = code;
|
||||
}
|
||||
};
|
||||
if (error instanceof StreamableHTTPError) {
|
||||
if (error.code !== undefined) attachHttpStatusIfValid(error.code);
|
||||
} else if (error instanceof SseError) {
|
||||
if (error.code !== undefined) attachHttpStatusIfValid(error.code);
|
||||
}
|
||||
const u = headerHolder?.lastUpstream401;
|
||||
if (u && headerHolder) {
|
||||
data.upstream401 = {
|
||||
wwwAuthenticate: u.wwwAuthenticate,
|
||||
body: u.body,
|
||||
contentType: u.contentType,
|
||||
};
|
||||
delete headerHolder.lastUpstream401;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function mcpProxy({
|
||||
transportToClient,
|
||||
transportToServer,
|
||||
headerHolder,
|
||||
}: {
|
||||
transportToClient: Transport;
|
||||
transportToServer: Transport;
|
||||
headerHolder?: ProxyHeaderHolder;
|
||||
}) {
|
||||
let transportToClientClosed = false;
|
||||
let transportToServerClosed = false;
|
||||
|
|
@ -28,18 +89,25 @@ export default function mcpProxy({
|
|||
let reportedServerSession = false;
|
||||
|
||||
transportToClient.onmessage = (message) => {
|
||||
transportToServer.send(message).catch((error) => {
|
||||
transportToServer.send(message).catch((err: unknown) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
// Send error response back to client if it was a request (has id) and connection is still open
|
||||
if (isJSONRPCRequest(message) && !transportToClientClosed) {
|
||||
const causeStr =
|
||||
error.cause !== undefined
|
||||
? error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: String(error.cause)
|
||||
: null;
|
||||
const errorResponse = {
|
||||
jsonrpc: "2.0" as const,
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: error.cause
|
||||
? `${error.message} (cause: ${error.cause})`
|
||||
code: MCP_PROXY_TRANSPORT_ERROR_CODE,
|
||||
message: causeStr
|
||||
? `${error.message} (cause: ${causeStr})`
|
||||
: error.message,
|
||||
data: error,
|
||||
data: serializeProxyTransportError(error, headerHolder),
|
||||
},
|
||||
};
|
||||
transportToClient.send(errorResponse).catch(onClientError);
|
||||
|
|
|
|||
Loading…
Reference in a new issue