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
1 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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue