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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-22 02:34:54 -03:00
committed by GitHub
parent ff320cbfd5
commit 6602af7478
9 changed files with 429 additions and 16 deletions

View File

@@ -0,0 +1 @@
- feat(mcp): add a narrow `mcp:connect` API-key scope for the `/api/mcp/` LOCAL_ONLY carve-out (separate from `manage`/`admin`) and populate `authInfo.scopes` from the caller's real per-key scopes over HTTP/SSE so `scopeEnforcement.ts` prefers per-key scopes over the `OMNIROUTE_MCP_SCOPES` env fallback when enforcement is enabled (#7895).

View File

@@ -308,6 +308,34 @@ MCP tools are authenticated through API key scopes. Scope enforcement is central
Wildcard scopes are supported: `read:*` grants all read-scopes, `*` grants full access.
### `mcp:connect` — narrow route capability (#7895)
Reaching the HTTP/SSE MCP transport (`/api/mcp/*`) from non-loopback requires the
`/api/mcp/` LOCAL_ONLY carve-out (see `docs/security/ROUTE_GUARD_TIERS.md`). Historically
that carve-out only accepted a full `manage`/`admin`-scope API key — too broad for a
caller that only needs to talk MCP. `src/shared/constants/managementScopes.ts` now
exports `MCP_CONNECT_SCOPE = "mcp:connect"`: an additive, narrow scope (same precedent as
`SELF_USAGE_SCOPE`) that authorizes ONLY the `/api/mcp/` bypass in
`src/server/authz/policies/management.ts` — it grants no other management-route access
and is deliberately kept OUT of `MANAGEMENT_API_KEY_SCOPES`. A key holding `manage`/`admin`
still passes the carve-out unchanged; `mcp:connect` is a lower-privilege alternative for
remote MCP-only callers, checked via `hasMcpConnectOrManageScope()`.
### Per-key HTTP scope binding (#7895)
Over HTTP/SSE, `open-sse/mcp-server/httpTransport.ts` now resolves the caller's real
`api_keys.scopes` via `resolveMcpCallerAuthInfo()` (`open-sse/mcp-server/httpAuthContext.ts`)
and passes it to the MCP SDK's `transport.handleRequest(req, { authInfo })`, so
`extra.authInfo.scopes` reaching each tool call reflects the Bearer key's own scopes.
`scopeEnforcement.ts`'s `resolveCallerScopeContext()` already prioritized `authInfo` over
the `_meta` and `OMNIROUTE_MCP_SCOPES` env fallback — this only populates that first,
highest-priority source, which was previously unfed over HTTP. When no API key resolves
(no header, invalid key), `authInfo` stays `undefined` and resolution falls through to the
existing `meta`/env chain unchanged. This does NOT flip `OMNIROUTE_MCP_ENFORCE_SCOPES`'s
default — enforcement still has to be explicitly enabled; this change only makes the
per-key path take precedence once it is. stdio has no per-caller identity (see
`mcpCallerIdentity.ts`) and is unaffected — it stays on the `_meta`/env fallback chain.
---
## Environment Variables

View File

@@ -73,13 +73,26 @@ Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` and
subprocesses (`npm install`, `node`), which is the exact CVE class the
LOCAL_ONLY tier exists to prevent.
| Request | Path | Result |
| ------------------------------------------- | -------------------------- | ------------------- |
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer without `manage` scope | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
**#7895`mcp:connect` narrow scope:** the `/api/mcp/` carve-out ALSO accepts
a Bearer key holding the narrow `mcp:connect` scope
(`src/shared/constants/managementScopes.ts::MCP_CONNECT_SCOPE`), checked via
`hasMcpConnectOrManageScope()` in `src/server/authz/policies/management.ts`.
This is scoped to `/api/mcp/` ONLY — `mcp:connect` grants nothing on any other
management route (including every other LOCAL_ONLY bypass prefix, should one
ever be added), and it is deliberately excluded from
`MANAGEMENT_API_KEY_SCOPES`. A key holding `manage`/`admin` still passes the
carve-out exactly as before; `mcp:connect` is a lower-privilege alternative
for remote MCP-only callers who should not need broad management access.
| Request | Path | Result |
| ------------------------------------------------- | -------------------------- | ------------------- |
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
#### Operator guidance & auditing

View File

@@ -1,4 +1,6 @@
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;
@@ -7,6 +9,19 @@ type McpHttpAuthContext = {
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 {
@@ -26,6 +41,34 @@ export function getMcpHttpAuthHeadersForInternalFetch(): Record<string, string>
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>

View File

@@ -11,7 +11,7 @@
import { randomUUID } from "node:crypto";
import { createMcpServer } from "./server.ts";
import { withMcpHttpAuthContext } from "./httpAuthContext.ts";
import { resolveMcpCallerAuthInfo, withMcpHttpAuthContext } from "./httpAuthContext.ts";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -135,6 +135,23 @@ async function isInitializeRequest(request: Request): Promise<boolean> {
}
}
/**
* Resolve the caller's per-key scopes (#7895) and hand the request to the
* transport with `authInfo` populated, so `extra.authInfo.scopes` reaching
* tool handlers reflects the real `api_keys.scopes` row instead of the
* `OMNIROUTE_MCP_SCOPES` env fallback. When no per-key auth can be resolved
* (no key, invalid key, stdio has no `Request` at all), `authInfo` stays
* `undefined` and `scopeEnforcement.ts` falls through to its existing
* meta/env chain unchanged.
*/
async function handleRequestWithAuthInfo(
transport: WebStandardStreamableHTTPServerTransport,
request: Request
): Promise<Response> {
const authInfo = await resolveMcpCallerAuthInfo(request);
return transport.handleRequest(request, { authInfo });
}
function errorResponse(message: string, code: number, status = 400): Response {
return new Response(
JSON.stringify({
@@ -182,7 +199,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
const newSession = createStreamableSession();
try {
const response = await withMcpHttpAuthContext(request, () =>
newSession.transport.handleRequest(request)
handleRequestWithAuthInfo(newSession.transport, request)
);
return withSessionHeader(response, newSession.sessionId);
} catch (err) {
@@ -200,7 +217,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
try {
session.lastActivityAt = Date.now();
const response = await withMcpHttpAuthContext(request, () =>
session.transport.handleRequest(request)
handleRequestWithAuthInfo(session.transport, request)
);
if (request.method === "DELETE") {
closeStreamableSession(sessionId);
@@ -226,7 +243,7 @@ async function handleStreamableRequest(request: Request): Promise<Response> {
try {
const response = await withMcpHttpAuthContext(request, () =>
session.transport.handleRequest(request)
handleRequestWithAuthInfo(session.transport, request)
);
return withSessionHeader(response, session.sessionId);
} catch (err) {
@@ -256,7 +273,7 @@ export async function handleMcpSSE(request: Request): Promise<Response> {
const { transport } = ensureSseServer();
try {
return await withMcpHttpAuthContext(request, () => transport.handleRequest(request));
return await withMcpHttpAuthContext(request, () => handleRequestWithAuthInfo(transport, request));
} catch (err) {
console.error("[MCP] SSE error:", err);
return new Response(JSON.stringify({ error: "MCP SSE transport error" }), {

View File

@@ -7,6 +7,7 @@ import { allow, reject } from "../context";
import { extractApiKey, isValidApiKey } from "../../../sse/services/auth";
import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
import { hasMcpConnectOrManageScope, MCP_CONNECT_SCOPE } from "../../../shared/constants/managementScopes";
import { evaluateAccessTokenAuth } from "../accessTokenAuth";
import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "../headers";
import { resolveStampedPeer, resolveStampedViaProxy } from "../peerStamp";
@@ -153,10 +154,26 @@ export const managementPolicy: RoutePolicy = {
try {
if (await isValidApiKey(apiKey)) {
const meta = await getApiKeyMetadata(apiKey);
if (meta && hasManageScope(meta.scopes)) {
// Distinguish admin vs manage in the audit label so log review
// can tell which privilege actually granted the bypass.
const grantedBy = meta.scopes.includes("admin") ? "admin" : "manage";
// #7895: the `/api/mcp/` carve-out ALSO accepts the narrow
// `mcp:connect` scope, so remote MCP-only callers don't need
// broad `manage`/`admin` just to reach the transport routes.
// Scoped to `/api/mcp/` ONLY — every other LOCAL_ONLY bypass
// prefix still requires full `hasManageScope` (below).
const scopeGranted =
path.startsWith("/api/mcp/") && meta
? hasMcpConnectOrManageScope(meta.scopes)
: Boolean(meta && hasManageScope(meta.scopes));
if (meta && scopeGranted) {
// Distinguish admin vs manage vs the narrow mcp:connect scope in
// the audit label so log review can tell which privilege
// actually granted the bypass.
const grantedBy = meta.scopes.includes("admin")
? "admin"
: meta.scopes.includes("manage")
? "manage"
: meta.scopes.includes(MCP_CONNECT_SCOPE)
? "mcp-connect"
: "manage";
return allow({
kind: "management_key",
id: meta.id,

View File

@@ -20,6 +20,29 @@ export const MANAGE_SCOPE = "manage";
*/
export const MANAGEMENT_API_KEY_SCOPES = new Set<string>(["manage", "admin"]);
/**
* Narrow, additive scope (#7895) that grants a non-loopback caller ONLY the
* `/api/mcp/` LOCAL_ONLY carve-out (see `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES`
* in `src/server/authz/routeGuard.ts`) — it does NOT grant broader management
* API access. Deliberately kept OUT of `MANAGEMENT_API_KEY_SCOPES`, mirroring the
* existing narrow-additive-scope precedent (`SELF_USAGE_SCOPE`,
* `API_KEY_BYPASS_PROVIDER_QUOTA_SCOPE`). A key holding `manage`/`admin` still
* passes the carve-out unchanged; `mcp:connect` is an alternative, lower-privilege
* path for remote MCP-only callers.
*/
export const MCP_CONNECT_SCOPE = "mcp:connect";
/**
* Check whether any of the given scopes authorizes the `/api/mcp/` LOCAL_ONLY
* carve-out specifically — i.e. either a full management scope (`manage`/`admin`)
* or the narrow `mcp:connect` scope. Use this ONLY for the `/api/mcp/` bypass
* check; every other management route must keep using `hasManageScope`.
*/
export function hasMcpConnectOrManageScope(scopes: readonly string[] = []): boolean {
if (hasManageScope(scopes)) return true;
return scopes.includes(MCP_CONNECT_SCOPE);
}
/**
* Check whether any of the given scopes authorizes management API access.
*/

View File

@@ -202,6 +202,7 @@
"tests/unit/management-auth-hardening.test.ts",
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",
"tests/unit/masked-200-exhaustion-fallback-6427.test.ts",
"tests/unit/mcp-connect-scope.test.ts",
"tests/unit/memory-embedding-remote.test.ts",
"tests/unit/memory-embedding-transformers.test.ts",
"tests/unit/microsoft-designer-web-6672.test.ts",

View File

@@ -0,0 +1,270 @@
// #7895 — narrow `mcp:connect` scope + per-key HTTP tool-scope binding for
// remote MCP. Security-critical (touches the LOCAL_ONLY manage-scope bypass,
// Hard Rules #15/#17) — dedicated regression tests, mirroring the harness in
// tests/unit/authz/management-policy.test.ts.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mcp-connect-scope-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
// API-key validation falls through to a Redis-backed cache otherwise — disable
// it for the local test loop so isValidApiKey() does not stall on ETIMEDOUT.
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const { managementPolicy } = await import("../../src/server/authz/policies/management.ts");
const {
isLocalOnlyPath,
isLocalOnlyBypassableByManageScope,
} = await import("../../src/server/authz/routeGuard.ts");
const { MCP_CONNECT_SCOPE, hasMcpConnectOrManageScope } = await import(
"../../src/shared/constants/managementScopes.ts"
);
const { resolveMcpCallerAuthInfo } = await import("../../open-sse/mcp-server/httpAuthContext.ts");
const { resolveCallerScopeContext, evaluateToolScopes } = await import(
"../../open-sse/mcp-server/scopeEnforcement.ts"
);
const ORIGINAL_JWT = process.env.JWT_SECRET;
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
function reset() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
}
test.beforeEach(() => {
reset();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = ORIGINAL_JWT;
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
});
function mgmtCtx(headers: Headers, method = "GET", pathname = "/api/keys") {
return {
request: {
method,
headers,
url: `http://localhost${pathname}`,
nextUrl: { pathname },
},
classification: {
routeClass: "MANAGEMENT" as const,
reason: "management_api" as const,
normalizedPath: pathname,
},
requestId: "req_mcp_connect_test",
};
}
async function seedAuthRequired() {
process.env.JWT_SECRET = "test-jwt-secret-for-mcp-connect-scope";
process.env.INITIAL_PASSWORD = "initial-pass";
await settingsDb.updateSettings({ requireLogin: true });
}
// ─── 1. mcp:connect-only key passes the /api/mcp/ carve-out ────────────────
test("mcp:connect-only key passes the /api/mcp/ LOCAL_ONLY carve-out from non-loopback", async () => {
await seedAuthRequired();
const created = await apiKeysDb.createApiKey("mcp-connect-only", "machine-mcp-connect", [
MCP_CONNECT_SCOPE,
]);
const out = await managementPolicy.evaluate(
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
);
assert.equal(out.allow, true);
if (out.allow) {
assert.equal(out.subject.kind, "management_key");
assert.equal(out.subject.id, created.id);
assert.ok(
(out.subject.label ?? "").includes("mcp-connect"),
`expected label to credit mcp-connect scope, got ${out.subject.label}`
);
}
});
// ─── 2. no manage/admin/mcp:connect → still rejected ────────────────────────
test("key with neither manage/admin nor mcp:connect is rejected for /api/mcp/ (403 LOCAL_ONLY)", async () => {
await seedAuthRequired();
const created = await apiKeysDb.createApiKey("chat-only-mcp", "machine-chat-only-mcp", ["chat"]);
const out = await managementPolicy.evaluate(
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
);
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 403);
assert.equal(out.code, "LOCAL_ONLY");
}
});
// ─── 3. mcp:connect does NOT open any other management route ───────────────
test("mcp:connect does NOT authorize a non-mcp management route (still needs manage/admin)", async () => {
await seedAuthRequired();
const created = await apiKeysDb.createApiKey("mcp-connect-scope-only", "machine-mcp-only-key", [
MCP_CONNECT_SCOPE,
]);
// /api/keys is NOT LOCAL_ONLY — it falls through to the generic
// bearer-token management-auth branch at the bottom of evaluate(), which
// must keep using plain hasManageScope() and reject mcp:connect.
const out = await managementPolicy.evaluate(
mgmtCtx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/keys")
);
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 403);
assert.equal(out.code, "AUTH_001");
}
});
test("hasMcpConnectOrManageScope: mcp:connect alone authorizes; manage/admin still do too", () => {
assert.equal(hasMcpConnectOrManageScope([MCP_CONNECT_SCOPE]), true);
assert.equal(hasMcpConnectOrManageScope(["manage"]), true);
assert.equal(hasMcpConnectOrManageScope(["admin"]), true);
assert.equal(hasMcpConnectOrManageScope(["chat"]), false);
assert.equal(hasMcpConnectOrManageScope([]), false);
});
// ─── 4. authInfo.scopes populated per-key over HTTP; enforcement uses them ──
test("resolveMcpCallerAuthInfo resolves the caller's real per-key scopes from a Bearer header", async () => {
const created = await apiKeysDb.createApiKey("scoped-http-caller", "machine-scoped-http", [
"read:health",
"read:combos",
]);
const request = new Request("http://localhost/api/mcp/stream", {
headers: { authorization: `Bearer ${created.key}` },
});
const authInfo = await resolveMcpCallerAuthInfo(request);
assert.ok(authInfo, "expected authInfo to resolve for a valid API key");
assert.equal(authInfo?.clientId, created.id);
assert.equal(authInfo?.token, created.key);
assert.deepEqual([...(authInfo?.scopes ?? [])].sort(), ["read:combos", "read:health"]);
});
test("resolveMcpCallerAuthInfo returns undefined without a resolvable API key (no false grant)", async () => {
const request = new Request("http://localhost/api/mcp/stream");
const authInfo = await resolveMcpCallerAuthInfo(request);
assert.equal(authInfo, undefined);
});
test("per-key authInfo.scopes takes precedence over the env fallback once resolved", async () => {
const created = await apiKeysDb.createApiKey("scoped-enforce-caller", "machine-scoped-enforce", [
"read:health",
]);
const request = new Request("http://localhost/api/mcp/stream", {
headers: { authorization: `Bearer ${created.key}` },
});
const authInfo = await resolveMcpCallerAuthInfo(request);
assert.ok(authInfo);
// Mirror what httpTransport.ts hands the MCP SDK: extra.authInfo populated
// from the per-key lookup. scopeEnforcement.ts must prefer it over any env
// fallback scopes, even when the env fallback would have granted access.
const scopeContext = resolveCallerScopeContext({ authInfo }, ["write:combos"]);
assert.equal(scopeContext.source, "authInfo");
assert.deepEqual(scopeContext.scopes, ["read:health"]);
const allowedCheck = evaluateToolScopes(
"irrelevant-tool-name",
scopeContext.scopes,
true,
["read:health"]
);
assert.equal(allowedCheck.allowed, true);
const deniedCheck = evaluateToolScopes(
"irrelevant-tool-name",
scopeContext.scopes,
true,
["write:combos"]
);
assert.equal(deniedCheck.allowed, false, "per-key scopes must gate, not the wider env fallback");
});
// ─── 5. stdio path unaffected — still env fallback ──────────────────────────
test("stdio path is unaffected: with no authInfo/meta, scope resolution still falls back to env scopes", () => {
// stdio tool handlers never populate extra.authInfo (no per-caller identity
// over stdio — see mcpCallerIdentity.ts) and never call
// resolveMcpCallerAuthInfo (HTTP/SSE-only, see httpTransport.ts). The only
// scope source left for them is the OMNIROUTE_MCP_SCOPES env fallback.
const scopeContext = resolveCallerScopeContext({ sessionId: "stdio-session" }, ["read:health"]);
assert.equal(scopeContext.source, "env");
assert.deepEqual(scopeContext.scopes, ["read:health"]);
});
test("stdio entrypoint (server.ts) does not import the HTTP-only authInfo resolver", () => {
const serverSource = fs.readFileSync(
new URL("../../open-sse/mcp-server/server.ts", import.meta.url),
"utf8"
);
assert.ok(
serverSource.includes("StdioServerTransport"),
"sanity check: server.ts still wires the stdio transport"
);
assert.ok(
!serverSource.includes("resolveMcpCallerAuthInfo"),
"resolveMcpCallerAuthInfo is HTTP/SSE-only (httpTransport.ts) and must not leak into the shared server.ts used by stdio"
);
});
// ─── 6. isLocalOnlyPath()/bypass regression guard (Hard Rule #15/#17) ───────
test("isLocalOnlyPath/isLocalOnlyBypassableByManageScope classification is unchanged by #7895", () => {
assert.equal(isLocalOnlyPath("/api/mcp/sse"), true);
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/sse"), true);
// /api/cli-tools/runtime/* stays LOCAL_ONLY and NON-bypassable — mcp:connect
// must not leak the carve-out to other spawn-capable prefixes.
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/foo"), true);
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
});
test("mcp:connect key is still rejected for the non-bypassable /api/cli-tools/runtime/* prefix", async () => {
await seedAuthRequired();
const created = await apiKeysDb.createApiKey("mcp-connect-cli-runtime", "machine-mcp-cli", [
MCP_CONNECT_SCOPE,
]);
const out = await managementPolicy.evaluate(
mgmtCtx(
new Headers({ authorization: `Bearer ${created.key}` }),
"GET",
"/api/cli-tools/runtime/foo"
)
);
assert.equal(out.allow, false);
if (!out.allow) {
assert.equal(out.status, 403);
assert.equal(out.code, "LOCAL_ONLY");
}
});