diff --git a/package-lock.json b/package-lock.json index 075b0a28..0de6103b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.15.0", "@modelcontextprotocol/inspector-client": "^0.15.0", "@modelcontextprotocol/inspector-server": "^0.15.0", - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.15.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", @@ -2009,15 +2009,17 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.1.tgz", - "integrity": "sha512-8q6+9aF0yA39/qWT/uaIj6zTpC+Qu07DnN/lb9mjoquCJsAh6l3HyYqc9O3t2j7GilseOQOQimLg7W3By6jqvg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.0.tgz", + "integrity": "sha512-67hnl/ROKdb03Vuu0YOr+baKTvf1/5YBHBm9KnZdjdAh8hjt4FRCPD5ucwxGB237sBpzlqQsLy1PFu7z/ekZ9Q==", + "license": "MIT", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", diff --git a/package.json b/package.json index 5d5fe52f..cd8f2d24 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@modelcontextprotocol/inspector-cli": "^0.15.0", "@modelcontextprotocol/inspector-client": "^0.15.0", "@modelcontextprotocol/inspector-server": "^0.15.0", - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.15.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", diff --git a/server/src/index.ts b/server/src/index.ts index 971cf158..714ea998 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -83,10 +83,30 @@ const getHttpHeaders = ( 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 maybeSetAuthHeader = (res: express.Response, header?: string) => { + if (header) { + res.setHeader("WWW-Authenticate", header); + } +}; + +const setAuthHeaderFromError = (res: express.Response, error: unknown) => { + if ( + error && + typeof error === "object" && + "authHeader" in error && + typeof error.authHeader === "string" + ) { + maybeSetAuthHeader(res, error.authHeader); + } +}; + const webAppTransports: Map = new Map(); // Web app transports by web app sessionId const serverTransports: Map = new Map(); // Server Transports by web app sessionId @@ -171,66 +191,89 @@ const authMiddleware = ( next(); }; -const createTransport = async (req: express.Request): Promise => { +const createTransport = async ( + req: express.Request, +): Promise<{ transport: Transport; authHeader?: string }> => { const query = req.query; console.log("Query parameters:", JSON.stringify(query)); + const originalFetch = globalThis.fetch; + let authHeader: string | undefined; + + const interceptingFetch = async ( + ...args: Parameters + ): Promise => { + const response = await originalFetch(...args); + if (response.status === 401 && response.headers.has("WWW-Authenticate")) { + authHeader = response.headers.get("WWW-Authenticate") ?? undefined; + } + return response; + }; + const transportType = query.transportType as string; - if (transportType === "stdio") { - const command = query.command as string; - const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; + try { + if (transportType === "stdio") { + const command = query.command as string; + const origArgs = shellParseArgs(query.args as string) as string[]; + const queryEnv = query.env ? JSON.parse(query.env as string) : {}; + const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; - const { cmd, args } = findActualExecutable(command, origArgs); + const { cmd, args } = findActualExecutable(command, origArgs); - console.log(`STDIO transport: command=${cmd}, args=${args}`); + console.log(`STDIO transport: command=${cmd}, args=${args}`); - const transport = new StdioClientTransport({ - command: cmd, - args, - env, - stderr: "pipe", - }); + const transport = new StdioClientTransport({ + command: cmd, + args, + env, + stderr: "pipe", + }); - await transport.start(); - return transport; - } else if (transportType === "sse") { - const url = query.url as string; + await transport.start(); + return { transport, authHeader }; + } else if (transportType === "sse") { + const url = query.url as string; - const headers = getHttpHeaders(req, transportType); + const headers = getHttpHeaders(req, transportType); - console.log( - `SSE transport: url=${url}, headers=${JSON.stringify(headers)}`, - ); + console.log( + `SSE transport: url=${url}, headers=${JSON.stringify(headers)}`, + ); - const transport = new SSEClientTransport(new URL(url), { - eventSourceInit: { - fetch: (url, init) => fetch(url, { ...init, headers }), - }, - requestInit: { - headers, - }, - }); - await transport.start(); - return transport; - } else if (transportType === "streamable-http") { - const headers = getHttpHeaders(req, transportType); - - const transport = new StreamableHTTPClientTransport( - new URL(query.url as string), - { + const transport = new SSEClientTransport(new URL(url), { requestInit: { headers, }, - }, - ); - await transport.start(); - return transport; - } else { - console.error(`Invalid transport type: ${transportType}`); - throw new Error("Invalid transport type specified"); + fetch: (url, init) => interceptingFetch(url, { ...init, headers }), + }); + await transport.start(); + return { transport, authHeader }; + } else if (transportType === "streamable-http") { + const headers = getHttpHeaders(req, transportType); + + const transport = new StreamableHTTPClientTransport( + new URL(query.url as string), + { + requestInit: { + headers, + }, + fetch: (url, init) => interceptingFetch(url, { ...init, headers }), + }, + ); + await transport.start(); + return { transport, authHeader }; + } else { + console.error(`Invalid transport type: ${transportType}`); + throw new Error("Invalid transport type specified"); + } + } catch (error) { + if (error && typeof error === "object") { + (error as { authHeader?: string }).authHeader = authHeader; + } + throw error; + } finally { + // nothing to clean up } }; @@ -269,8 +312,11 @@ app.post( try { console.log("New StreamableHttp connection request"); try { - serverTransport = await createTransport(req); + const { transport, authHeader } = await createTransport(req); + serverTransport = transport; + maybeSetAuthHeader(res, authHeader); } catch (error) { + setAuthHeaderFromError(res, error); if (error instanceof SseError && error.code === 401) { console.error( "Received 401 Unauthorized from MCP server:", @@ -374,9 +420,12 @@ app.get( console.log("New STDIO connection request"); let serverTransport: Transport | undefined; try { - serverTransport = await createTransport(req); + const { transport, authHeader } = await createTransport(req); + serverTransport = transport; console.log("Created server transport"); + maybeSetAuthHeader(res, authHeader); } catch (error) { + setAuthHeaderFromError(res, error); if (error instanceof SseError && error.code === 401) { console.error( "Received 401 Unauthorized from MCP server. Authentication failure.", @@ -443,8 +492,11 @@ app.get( ); let serverTransport: Transport | undefined; try { - serverTransport = await createTransport(req); + const { transport, authHeader } = await createTransport(req); + serverTransport = transport; + maybeSetAuthHeader(res, authHeader); } catch (error) { + setAuthHeaderFromError(res, error); if (error instanceof SseError && error.code === 401) { console.error( "Received 401 Unauthorized from MCP server. Authentication failure.",