From 3ee53cd56138d2893ef6413aaaf5ad91f13dc71e Mon Sep 17 00:00:00 2001 From: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:58:50 -0700 Subject: [PATCH] fix(mcp): forward HTTP auth to internal tool fetches (#5218) Forward MCP HTTP auth to internal tool fetches via AsyncLocalStorage (#5211). Rebased onto release tip. Integrated into release/v3.8.40. --- .../__tests__/httpAuthContext.test.ts | 143 ++++++++++++++++++ open-sse/mcp-server/httpAuthContext.ts | 42 +++++ open-sse/mcp-server/httpTransport.ts | 11 +- open-sse/mcp-server/server.ts | 69 ++++----- open-sse/mcp-server/tools/advancedTools.ts | 4 +- 5 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 open-sse/mcp-server/__tests__/httpAuthContext.test.ts create mode 100644 open-sse/mcp-server/httpAuthContext.ts diff --git a/open-sse/mcp-server/__tests__/httpAuthContext.test.ts b/open-sse/mcp-server/__tests__/httpAuthContext.test.ts new file mode 100644 index 0000000000..b90a7b30d6 --- /dev/null +++ b/open-sse/mcp-server/__tests__/httpAuthContext.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { createMcpServer } from "../server.ts"; +import { + getMcpHttpAuthHeadersForInternalFetch, + withMcpHttpAuthContext, +} from "../httpAuthContext.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +describe("MCP HTTP auth context", () => { + it("forwards bearer and cookie credentials to in-process internal fetches", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { + Authorization: "Bearer manage-key", + Cookie: "auth_token=session-token", + }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({ + Authorization: "Bearer manage-key", + Cookie: "auth_token=session-token", + }); + }); + + it("forwards Anthropic-style x-api-key only with its contract header", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { + "x-api-key": "manage-key", + "anthropic-version": "2023-06-01", + }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({ + "x-api-key": "manage-key", + "anthropic-version": "2023-06-01", + }); + }); + + it("does not forward bare x-api-key without anthropic-version", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { "x-api-key": "placeholder" }, + }); + + const headers = await withMcpHttpAuthContext(request, async () => + getMcpHttpAuthHeadersForInternalFetch() + ); + + expect(headers).toEqual({}); + }); + + it("does not leak auth context outside the wrapped request", async () => { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + + await withMcpHttpAuthContext(request, async () => { + expect(getMcpHttpAuthHeadersForInternalFetch()).toEqual({ + Authorization: "Bearer manage-key", + }); + }); + + expect(getMcpHttpAuthHeadersForInternalFetch()).toEqual({}); + }); + + it("forwards request auth through registered core tools", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ combos: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + + try { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + await withMcpHttpAuthContext(request, () => + client.callTool({ name: "omniroute_list_combos", arguments: {} }) + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/combos"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer manage-key" }), + }) + ); + } finally { + await client.close(); + vi.unstubAllGlobals(); + } + }); + + it("forwards request auth through advanced tool apiFetch", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ entries: 2, hitRate: 0.5 }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + const client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(clientTransport); + + try { + const request = new Request("http://localhost/api/mcp/stream", { + headers: { Authorization: "Bearer manage-key" }, + }); + await withMcpHttpAuthContext(request, () => + client.callTool({ name: "omniroute_cache_stats", arguments: {} }) + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/cache"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer manage-key" }), + }) + ); + } finally { + await client.close(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/open-sse/mcp-server/httpAuthContext.ts b/open-sse/mcp-server/httpAuthContext.ts new file mode 100644 index 0000000000..a070f19e88 --- /dev/null +++ b/open-sse/mcp-server/httpAuthContext.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +type McpHttpAuthContext = { + authorization?: string; + cookie?: string; + xApiKey?: string; + anthropicVersion?: string; +}; + +const mcpHttpAuthContext = new AsyncLocalStorage(); + +function headerValue(request: Request, name: string): string | undefined { + const value = request.headers.get(name); + return value && value.trim().length > 0 ? value : undefined; +} + +export function getMcpHttpAuthHeadersForInternalFetch(): Record { + const context = mcpHttpAuthContext.getStore(); + const headers: Record = {}; + if (context?.authorization) headers.Authorization = context.authorization; + if (context?.cookie) headers.Cookie = context.cookie; + if (context?.xApiKey && context?.anthropicVersion) { + headers["x-api-key"] = context.xApiKey; + headers["anthropic-version"] = context.anthropicVersion; + } + return headers; +} + +export async function withMcpHttpAuthContext( + request: Request, + callback: () => Promise +): Promise { + return mcpHttpAuthContext.run( + { + authorization: headerValue(request, "authorization"), + cookie: headerValue(request, "cookie"), + xApiKey: headerValue(request, "x-api-key"), + anthropicVersion: headerValue(request, "anthropic-version"), + }, + callback + ); +} diff --git a/open-sse/mcp-server/httpTransport.ts b/open-sse/mcp-server/httpTransport.ts index 1cee592e6a..67448e15d0 100644 --- a/open-sse/mcp-server/httpTransport.ts +++ b/open-sse/mcp-server/httpTransport.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import { createMcpServer } from "./server.ts"; +import { withMcpHttpAuthContext } from "./httpAuthContext.ts"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -177,7 +178,9 @@ async function handleStreamableRequest(request: Request): Promise { try { session.lastActivityAt = Date.now(); - const response = await session.transport.handleRequest(request); + const response = await withMcpHttpAuthContext(request, () => + session.transport.handleRequest(request) + ); if (request.method === "DELETE") { closeStreamableSession(sessionId); } @@ -201,7 +204,9 @@ async function handleStreamableRequest(request: Request): Promise { const session = createStreamableSession(); try { - const response = await session.transport.handleRequest(request); + const response = await withMcpHttpAuthContext(request, () => + session.transport.handleRequest(request) + ); return withSessionHeader(response, session.sessionId); } catch (err) { closeStreamableSession(session.sessionId); @@ -230,7 +235,7 @@ export async function handleMcpSSE(request: Request): Promise { const { transport } = ensureSseServer(); try { - return await transport.handleRequest(request); + return await withMcpHttpAuthContext(request, () => transport.handleRequest(request)); } catch (err) { console.error("[MCP] SSE error:", err); return new Response(JSON.stringify({ error: "MCP SSE transport error" }), { diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 85eb0182e0..49f0a02d7a 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -1,15 +1,3 @@ -/** - * OmniRoute MCP Server — Model Context Protocol server exposing - * OmniRoute gateway intelligence as tools for AI agents. - * - * Supports two transports: - * 1. stdio — for IDE integration (VS Code, Cursor, Claude Desktop) - * 2. HTTP — for remote/programmatic access - * - * Tools wrap existing OmniRoute API endpoints and add intelligence - * such as routing simulation, budget guards, and session snapshots. - */ - import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -56,6 +44,7 @@ import { resolveCallerScopeContext, type McpToolExtraLike, } from "./scopeEnforcement.ts"; +import { getMcpHttpAuthHeadersForInternalFetch } from "./httpAuthContext.ts"; import { handleSimulateRoute, @@ -101,8 +90,6 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; -// ============ Configuration ============ - const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; const MCP_ALLOWED_SCOPES = new Set( @@ -217,9 +204,6 @@ function normalizeComboModels( }); } -/** - * Internal fetch helper that calls OmniRoute API endpoints. - */ function getOmniRouteApiKey(): string { return process.env.OMNIROUTE_API_KEY || ""; } @@ -229,6 +213,7 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< const apiKey = getOmniRouteApiKey(); const headers: Record = { "Content-Type": "application/json", + ...getMcpHttpAuthHeadersForInternalFetch(), ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), ...((options.headers as Record) || {}), }; @@ -298,9 +283,17 @@ function getCatalogModelCapabilities(model: JsonRecord): string[] { return ["chat"]; } -function normalizeCatalogStatus(model: JsonRecord, source: string, warning?: string): McpCatalogStatus { +function normalizeCatalogStatus( + model: JsonRecord, + source: string, + warning?: string +): McpCatalogStatus { const explicitStatus = toString(model.status); - if (explicitStatus === "available" || explicitStatus === "degraded" || explicitStatus === "unavailable") { + if ( + explicitStatus === "available" || + explicitStatus === "degraded" || + explicitStatus === "unavailable" + ) { return explicitStatus; } @@ -358,7 +351,8 @@ export async function getMcpModelsCatalog( connections = Array.isArray(connections) ? connections : []; const activeConnections = connections.filter((connection) => { - const provider = typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; + const provider = + typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; if (!provider || !connection?.id || connection.isActive === false) return false; if (requestedProvider && provider !== requestedProvider) return false; return true; @@ -371,7 +365,9 @@ export async function getMcpModelsCatalog( })); if (requestedProvider && requestSpecs.length === 0) { - const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some((provider) => provider.id === requestedProvider); + const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some( + (provider) => provider.id === requestedProvider + ); if (isNoAuthProvider) { requestSpecs.push({ provider: requestedProvider, @@ -393,7 +389,10 @@ export async function getMcpModelsCatalog( for (const spec of requestSpecs) { const raw = toRecord(await fetchJson(spec.path)); - const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog"); + const source = toString( + raw.source, + spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog" + ); const warning = raw.warning ? String(raw.warning) : undefined; if (warning) warnings.add(warning); sources.add(source); @@ -433,7 +432,12 @@ function withScopeEnforcement( ) { return async (args: unknown, extra?: McpToolExtraLike): Promise => { const scopeContext = resolveCallerScopeContext(extra, Array.from(MCP_ALLOWED_SCOPES)); - const scopeCheck = evaluateToolScopes(toolName, scopeContext.scopes, MCP_ENFORCE_SCOPES, toolScopes); + const scopeCheck = evaluateToolScopes( + toolName, + scopeContext.scopes, + MCP_ENFORCE_SCOPES, + toolScopes + ); if (!scopeCheck.allowed) { const missingScopes = scopeCheck.missing.length > 0 ? scopeCheck.missing.join(", ") : "unavailable"; @@ -470,8 +474,6 @@ function withScopeEnforcement( }; } -// ============ Tool Handlers ============ - async function handleGetHealth() { const start = Date.now(); try { @@ -822,11 +824,6 @@ async function handleWebFetch(args: { } } -// ============ MCP Server Setup ============ - -/** - * Create and configure the OmniRoute MCP Server with all essential tools. - */ export function createMcpServer(): McpServer { const server = new McpServer({ name: "omniroute", @@ -898,7 +895,6 @@ export function createMcpServer(): McpServer { ...notionTools.map((t) => t.name), ]); - // Register essential tools server.registerTool( "omniroute_get_health", { @@ -990,8 +986,6 @@ export function createMcpServer(): McpServer { ) ); - // ── Advanced Tools (Phase 3) ────────────────────────────── - server.registerTool( "omniroute_simulate_route", { @@ -1143,9 +1137,7 @@ export function createMcpServer(): McpServer { "Fetches and extracts content from a URL using OmniRoute's web fetch gateway. Supports multiple providers (Firecrawl, Jina Reader, Tavily) with automatic failover. Returns the page content as markdown, HTML, links, or screenshot, along with metadata.", inputSchema: webFetchInput, }, - withScopeEnforcement("omniroute_web_fetch", (args) => - handleWebFetch(webFetchInput.parse(args)) - ) + withScopeEnforcement("omniroute_web_fetch", (args) => handleWebFetch(webFetchInput.parse(args))) ); server.registerTool( @@ -1170,8 +1162,6 @@ export function createMcpServer(): McpServer { ) ); - // ── 1proxy Tools ────────────────────────────── - server.registerTool( "omniroute_oneproxy_fetch", { @@ -1459,7 +1449,8 @@ export function createMcpServer(): McpServer { }); // ── Dynamic Skill Tools (from skills table) ── - const skillToMcpToolName = (skill: { name: string }) => `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`; + const skillToMcpToolName = (skill: { name: string }) => + `skill_${skill.name.replace(/[^a-z0-9_-]/gi, "_")}`; try { const enabledSkills = skillRegistry.list().filter((s) => s.enabled); for (const skill of enabledSkills) { diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 86dc3930ed..0efa428126 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -14,11 +14,10 @@ * 9. omniroute_get_session_snapshot — Full session state snapshot * 10. omniroute_db_health_check — Diagnose and repair DB state drift * 11. omniroute_sync_pricing — Sync provider pricing from external source - * 12. omniroute_cache_stats — Cache statistics and hit rates - * 13. omniroute_cache_flush — Flush/invalidate cache entries */ import { logToolCall } from "../audit.ts"; +import { getMcpHttpAuthHeadersForInternalFetch } from "../httpAuthContext.ts"; import { normalizeQuotaResponse } from "../../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { @@ -39,6 +38,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise = { "Content-Type": "application/json", + ...getMcpHttpAuthHeadersForInternalFetch(), ...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}), ...((options.headers as Record) || {}), };