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.
This commit is contained in:
KooshaPari
2026-06-28 08:58:50 -07:00
committed by GitHub
parent 3ab9723a7b
commit 3ee53cd561
5 changed files with 225 additions and 44 deletions

View File

@@ -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();
}
});
});

View File

@@ -0,0 +1,42 @@
import { AsyncLocalStorage } from "node:async_hooks";
type McpHttpAuthContext = {
authorization?: string;
cookie?: string;
xApiKey?: string;
anthropicVersion?: string;
};
const mcpHttpAuthContext = new AsyncLocalStorage<McpHttpAuthContext>();
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<string, string> {
const context = mcpHttpAuthContext.getStore();
const headers: Record<string, string> = {};
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<T>(
request: Request,
callback: () => Promise<T>
): Promise<T> {
return mcpHttpAuthContext.run(
{
authorization: headerValue(request, "authorization"),
cookie: headerValue(request, "cookie"),
xApiKey: headerValue(request, "x-api-key"),
anthropicVersion: headerValue(request, "anthropic-version"),
},
callback
);
}

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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" }), {

View File

@@ -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<string, string> = {
"Content-Type": "application/json",
...getMcpHttpAuthHeadersForInternalFetch(),
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...((options.headers as Record<string, string>) || {}),
};
@@ -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<TextToolResult> => {
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) {

View File

@@ -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<unknow
const url = `${OMNIROUTE_BASE_URL}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getMcpHttpAuthHeadersForInternalFetch(),
...(OMNIROUTE_API_KEY ? { Authorization: `Bearer ${OMNIROUTE_API_KEY}` } : {}),
...((options.headers as Record<string, string>) || {}),
};