mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* 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)
86 lines
3.1 KiB
TypeScript
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
|
|
);
|
|
}
|