mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
143
open-sse/mcp-server/__tests__/httpAuthContext.test.ts
Normal file
143
open-sse/mcp-server/__tests__/httpAuthContext.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
42
open-sse/mcp-server/httpAuthContext.ts
Normal file
42
open-sse/mcp-server/httpAuthContext.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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" }), {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>) || {}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user