Files
OmniRoute/open-sse/mcp-server/httpAuthContext.ts
Diego Rodrigues de Sa e Souza 6602af7478 feat: narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895) (#7967)
* feat: narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895)

Adds MCP_CONNECT_SCOPE ("mcp:connect"), a narrow additive API-key scope
(kept out of MANAGEMENT_API_KEY_SCOPES, same precedent as SELF_USAGE_SCOPE)
that authorizes ONLY the /api/mcp/ LOCAL_ONLY route-guard carve-out --
remote MCP-only callers no longer need broad manage/admin scope just to
reach the transport routes. Scoped strictly to /api/mcp/; every other
LOCAL_ONLY bypass prefix still requires hasManageScope() unchanged.

Also resolves the caller's real api_keys.scopes over HTTP/SSE
(httpAuthContext.ts::resolveMcpCallerAuthInfo) and passes it to the MCP
SDK's transport.handleRequest(req, { authInfo }), so extra.authInfo.scopes
reaching tool calls reflects the Bearer key's own scopes instead of the
OMNIROUTE_MCP_SCOPES env fallback -- scopeEnforcement.ts already prioritized
authInfo, it was simply unfed over HTTP. Does not flip the
OMNIROUTE_MCP_ENFORCE_SCOPES default; stdio is unaffected (no per-caller
identity, stays on the meta/env fallback chain).

Closes #7895

* test(mcp): register mcp-connect-scope test in stryker tap.testFiles (#7895)
2026-07-22 02:34:54 -03:00

86 lines
3.1 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import { extractApiKey, isValidApiKey } from "../../src/sse/services/auth.ts";
import { getApiKeyMetadata } from "../../src/lib/db/apiKeys.ts";
type McpHttpAuthContext = {
authorization?: string;
cookie?: string;
xApiKey?: string;
anthropicVersion?: string;
};
/**
* Minimal shape of the MCP SDK's `AuthInfo` (server/auth/types.ts) that
* `httpTransport.ts` passes into `transport.handleRequest(req, { authInfo })`
* so per-tool-call `extra.authInfo` — and therefore
* `scopeEnforcement.ts::resolveCallerScopeContext` — sees the caller's real
* per-key scopes instead of falling back to the `OMNIROUTE_MCP_SCOPES` env var.
*/
export type McpCallerAuthInfo = {
token: string;
clientId: string;
scopes: 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;
}
/**
* Resolve the caller's real per-key `api_keys.scopes` for one HTTP/SSE MCP
* request, for #7895's per-key scope binding. Returns `undefined` when the
* request carries no resolvable API key (no header, invalid key, or the
* DB/auth backend throws) — callers MUST treat `undefined` as "no per-key
* authInfo available", NOT as "zero scopes", so `scopeEnforcement.ts` falls
* through to its existing `meta` → env fallback chain unchanged. Only the
* HTTP/SSE transports call this; stdio has no per-caller identity and stays
* on the env fallback (see `docs/frameworks/MCP-SERVER.md`).
*/
export async function resolveMcpCallerAuthInfo(
request: Request
): Promise<McpCallerAuthInfo | undefined> {
const rawKey = extractApiKey(request, { allowUrl: false });
if (!rawKey) return undefined;
try {
if (!(await isValidApiKey(rawKey))) return undefined;
const meta = await getApiKeyMetadata(rawKey);
if (!meta || !meta.id) return undefined;
return { token: rawKey, clientId: String(meta.id), scopes: meta.scopes ?? [] };
} catch {
// Fail closed: an unresolved caller falls through to the meta/env scope
// chain rather than ever synthesizing a false per-key scope grant.
return undefined;
}
}
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
);
}