fix(mcp): honor the mcp:connect carve-out in transport route guards (#11139)

Validated on the combined batch board over tip 17897cc3: gates clean (changelog, file-size 159 frozen, complexity 2628<=2774, cognitive 1184<=1223, dead-code 409<=416, provider-consistency 267/350/0), typecheck:core clean, 83/83 PR suites + neighbors green (mcp-route-scope-carveout 15 tests with bug-injection proof, management-auth-hardening per-route call-shape pinning). The route layer now honors the #9159 mcp:connect carve-out exactly like the policy layer — audit/audit-stats deliberately stay manage-only, oma_ tokens still require admin. Thank you @HouMinXi!
This commit is contained in:
Bob.Hou
2026-08-23 01:18:06 -04:00
committed by GitHub
parent 0a7bd62401
commit dea5345397
7 changed files with 345 additions and 12 deletions

View File

@@ -30,7 +30,7 @@ async function guardEnabled(): Promise<NextResponse | null> {
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
const blocked = await guardEnabled();
if (blocked) return blocked;
@@ -38,7 +38,7 @@ export async function GET(request: NextRequest) {
}
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
const blocked = await guardEnabled();
if (blocked) return blocked;

View File

@@ -14,7 +14,7 @@ import { getCachedSettings } from "@/lib/db/settings";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
try {
const [heartbeat, stats, lastCallPage, settings] = await Promise.all([

View File

@@ -33,7 +33,7 @@ async function guardEnabled(): Promise<NextResponse | null> {
}
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
const blocked = await guardEnabled();
if (blocked) return blocked;
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
const blocked = await guardEnabled();
if (blocked) return blocked;
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest) {
}
export async function DELETE(request: NextRequest) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
const blocked = await guardEnabled();
if (blocked) return blocked;

View File

@@ -3,7 +3,7 @@ import { MCP_TOOLS, MCP_TOOL_MAP } from "@omniroute/open-sse/mcp-server/schemas/
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });
if (authError) return authError;
try {
return NextResponse.json({

View File

@@ -8,7 +8,9 @@ import { isTrustedLoopbackInternalServiceRequest } from "@/lib/api/internalServi
import { AUTHZ_HEADER_AUTH_KIND, AUTHZ_HEADER_AUTH_LABEL } from "@/server/authz/headers";
import {
MANAGE_SCOPE,
MCP_CONNECT_SCOPE,
hasManageScope as hasManageScopeShared,
hasMcpConnectOrManageScope,
} from "@/shared/constants/managementScopes";
export { MANAGE_SCOPE };
@@ -26,6 +28,13 @@ export function hasManageScope(scopes: string[] = []): boolean {
interface RequireManagementAuthOptions {
alwaysRequireAuth?: boolean;
invalidApiKeyStatus?: 401 | 403;
/**
* Accept the narrow `mcp:connect` scope in the API-key branch, mirroring the
* #9159 carve-out the central managementPolicy already applies to /api/mcp/*
* paths. Only the MCP transport routes (stream/sse/status/tools) may enable
* this — every other management route stays manage/admin-only.
*/
acceptMcpConnectScope?: boolean;
}
function invalidManagementTokenResponse(options: RequireManagementAuthOptions): Response {
@@ -117,11 +126,26 @@ export async function requireManagementAuth(
});
}
if (meta && hasManageScope(meta.scopes)) return null;
// API-key branch: with acceptMcpConnectScope (MCP transport routes) the
// #9159 carve-out applies — hasMcpConnectOrManageScope accepts manage,
// admin, and mcp:connect. Without it, the guard stays manage-only. A null
// meta (valid key, metadata unavailable — deleted mid-request) falls
// through to the same 403 as the default path for every caller, keeping
// the error contract uniform.
if (
meta &&
(options.acceptMcpConnectScope
? hasMcpConnectOrManageScope(meta.scopes)
: hasManageScope(meta.scopes))
) {
return null;
}
return createErrorResponse({
status: 403,
message: "API key lacks 'manage' scope. Enable it in the API Keys dashboard.",
message: options.acceptMcpConnectScope
? `API key lacks '${MCP_CONNECT_SCOPE}' (or 'manage') scope. Enable it in the API Keys dashboard.`
: "API key lacks 'manage' scope. Enable it in the API Keys dashboard.",
type: "invalid_request",
});
}

View File

@@ -215,6 +215,9 @@ test("MCP transport and inspection routes require management authentication", ()
// route must self-enforce. requireManagementAuth covers: CLI machine
// token (loopback), dashboard session cookie, and manage-scope API key —
// matching the documented bypasses in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES.
// The transport routes (stream/sse/status/tools) pass
// { acceptMcpConnectScope: true }, the route-layer half of the #9159
// mcp:connect carve-out; audit/audit/stats stay manage-only.
const routePaths = [
"src/app/api/mcp/status/route.ts",
"src/app/api/mcp/tools/route.ts",
@@ -227,12 +230,53 @@ test("MCP transport and inspection routes require management authentication", ()
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
// Transport routes must use the carve-out form; audit routes must use
// the bare form. The per-route shape is pinned exactly — reverting a
// transport route to the bare call fails here.
const isAudit = routePath.includes("/audit");
const hasOption = content.includes(
"const authError = await requireManagementAuth(request, { acceptMcpConnectScope: true });"
);
// Audit routes must use the BARE call — any second argument at all
// (even an unrelated option) widens their auth surface.
const hasAnyOption = /requireManagementAuth\(request,\s*\{/.test(content);
const hasBare = content.includes("const authError = await requireManagementAuth(request);");
assert.equal(
hasOption,
!isAudit,
`${routePath} carve-out shape mismatch (hasOption=${hasOption}, audit=${isAudit})`
);
if (isAudit) {
assert.ok(hasBare, `${routePath} must keep the bare guard call`);
assert.ok(!hasAnyOption, `${routePath} must not pass ANY options to the guard (manage-only)`);
}
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
// Carve-out hygiene: only the four transport routes may accept
// mcp:connect; the audit inspection routes remain manage-only.
for (const routePath of [
"src/app/api/mcp/audit/route.ts",
"src/app/api/mcp/audit/stats/route.ts",
]) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(
!content.includes("acceptMcpConnectScope"),
`${routePath} must stay manage-only (no mcp:connect carve-out)`
);
}
for (const routePath of [
"src/app/api/mcp/status/route.ts",
"src/app/api/mcp/tools/route.ts",
"src/app/api/mcp/sse/route.ts",
"src/app/api/mcp/stream/route.ts",
]) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(
content.includes("acceptMcpConnectScope: true"),
`${routePath} must enable the mcp:connect carve-out`
);
}
});
test("management routes sanitize error.message before returning it to clients", () => {

View File

@@ -0,0 +1,265 @@
// #9159 route-layer follow-up — the /api/mcp/* transport routes call
// requireManagementAuth() whose API-key branch only accepts the `manage`
// scope. The policy layer (managementPolicy) already carves out mcp:connect
// for /api/mcp/* paths, but the route handler's own check runs independently
// and rejects mcp:connect-only keys with 403 before the MCP transport ever
// starts — stranding MCP-only clients (remote search gateways) with keys far
// broader than the least-privilege design intended. These tests pin the route
// layer to the same carve-out contract the policy layer already enforces.
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-route-scope-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
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 accessTokensDb = await import("../../src/lib/db/accessTokens.ts");
const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts");
const { MCP_CONNECT_SCOPE } = await import("../../src/shared/constants/managementScopes.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;
});
async function seedAuthRequired() {
process.env.JWT_SECRET = "test-jwt-secret-for-mcp-route-scope";
process.env.INITIAL_PASSWORD = "initial-pass";
await settingsDb.updateSettings({ requireLogin: true });
}
async function seedKey(scopes: string[], machineId: string): Promise<string> {
const created = await apiKeysDb.createApiKey(`test-${scopes.join("-")}`, machineId, scopes);
// createApiKey returns the raw key only at creation time.
return created.key;
}
function mcpRequest(key: string, pathname = "/api/mcp/stream", method = "POST"): Request {
return new Request(`http://localhost:20128${pathname}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
});
}
test("mcp:connect-only key passes the route-layer guard on /api/mcp/stream", async () => {
await seedAuthRequired();
const key = await seedKey([MCP_CONNECT_SCOPE], "machine-route-mcp-connect");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "mcp:connect key must pass the MCP route guard");
});
test("mcp:connect-only key passes the guard for GET transport routes too", async () => {
await seedAuthRequired();
const key = await seedKey([MCP_CONNECT_SCOPE], "machine-route-mcp-get");
const err = await requireManagementAuth(mcpRequest(key, "/api/mcp/status", "GET"), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "guard is method-agnostic (GET status route)");
});
test("mcp:connect-only key passes the guard on the sse and tools transport routes", async () => {
await seedAuthRequired();
const key = await seedKey([MCP_CONNECT_SCOPE], "machine-route-mcp-sse-tools");
for (const [path, method] of [
["/api/mcp/sse", "GET"],
["/api/mcp/tools", "GET"],
] as const) {
const err = await requireManagementAuth(mcpRequest(key, path, method), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, `mcp:connect key must pass ${method} ${path}`);
}
});
test("manage-only routes keep the historical 403 message for insufficient scope", async () => {
await seedAuthRequired();
const key = await seedKey(["execute:search"], "machine-route-manage-msg");
const err = await requireManagementAuth(mcpRequest(key, "/api/providers"));
assert.ok(err !== null && err instanceof Response);
assert.equal(err.status, 403);
const body = (await err.json()) as { error?: { message?: string } | string };
const message = typeof body.error === "string" ? body.error : (body.error?.message ?? "");
assert.match(message, /API key lacks 'manage' scope\./, "default guard message unchanged");
});
test("mcp:connect-only rejection without the option pins the default 403 message", async () => {
await seedAuthRequired();
const key = await seedKey([MCP_CONNECT_SCOPE], "machine-route-default-msg");
const err = await requireManagementAuth(mcpRequest(key, "/api/providers"));
assert.ok(err !== null && err instanceof Response);
assert.equal(err.status, 403);
const body = (await err.json()) as { error?: { message?: string } | string };
const message = typeof body.error === "string" ? body.error : (body.error?.message ?? "");
assert.match(message, /API key lacks 'manage' scope\./);
});
test("admin-only key passes the MCP carve-out", async () => {
await seedAuthRequired();
const key = await seedKey(["admin"], "machine-route-admin-only");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "admin scope is accepted by hasMcpConnectOrManageScope");
});
test("a key with an empty scopes array is rejected", async () => {
await seedAuthRequired();
const key = await seedKey([], "machine-route-empty-scopes");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.ok(err !== null && err instanceof Response, "no scopes -> 403");
assert.equal(err.status, 403);
const body = (await err.json()) as { error?: { message?: string } | string };
const message = typeof body.error === "string" ? body.error : (body.error?.message ?? "");
assert.ok(message.length > 0, `unexpected error envelope: ${JSON.stringify(body)}`);
assert.match(message, /mcp:connect/);
});
test("admin + mcp:connect scopes pass the carve-out (no precedence bug)", async () => {
await seedAuthRequired();
const key = await seedKey(["admin", MCP_CONNECT_SCOPE], "machine-route-admin-connect");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "admin+mcp:connect combination must pass");
});
test("mcp:connect-only key is rejected by the DEFAULT guard (manage-only routes unchanged)", async () => {
await seedAuthRequired();
const key = await seedKey([MCP_CONNECT_SCOPE], "machine-route-mcp-default");
const err = await requireManagementAuth(mcpRequest(key, "/api/providers"));
assert.ok(err !== null, "without the option the guard stays manage-only");
assert.equal(err?.status, 403);
});
test("scope-less key is rejected with an actionable message mentioning mcp:connect", async () => {
await seedAuthRequired();
const key = await seedKey(["execute:search"], "machine-route-search-only");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.ok(err !== null, "insufficient scope must be rejected");
assert.ok(err instanceof Response);
const body = (await err.json()) as { error?: { message?: string } | string };
const message = typeof body.error === "string" ? body.error : (body.error?.message ?? "");
assert.ok(message.length > 0, `unexpected error envelope: ${JSON.stringify(body)}`);
// Pin the full actionable guidance, not just the scope token.
assert.match(
message,
/API key lacks 'mcp:connect' \(or 'manage'\) scope\./,
"403 message must name the required scopes"
);
});
test("manage-scope key keeps working with the MCP option enabled", async () => {
await seedAuthRequired();
const key = await seedKey(["manage", MCP_CONNECT_SCOPE], "machine-route-manage");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "manage+connect key must pass the MCP route guard");
});
test("an unauthenticated request is still rejected with the MCP option enabled", async () => {
await seedAuthRequired();
const noAuth = new Request("http://localhost:20128/api/mcp/stream", {
method: "POST",
headers: { Accept: "application/json, text/event-stream" },
});
const err = await requireManagementAuth(noAuth, {
acceptMcpConnectScope: true,
});
assert.ok(err !== null, "no credential must be rejected");
assert.ok(err instanceof Response);
assert.equal(err.status, 401, "scope carve-out must not bypass authentication");
});
test("when auth is not required (no JWT_SECRET), the guard passes everything — carve-out included", async () => {
// Deployment without requireLogin: every management route is open; the
// carve-out option changing nothing here is the pre-existing contract.
delete process.env.JWT_SECRET;
delete process.env.INITIAL_PASSWORD;
await settingsDb.updateSettings({ requireLogin: false });
const key = await seedKey([], "machine-route-noauth");
const err = await requireManagementAuth(mcpRequest(key), {
acceptMcpConnectScope: true,
});
assert.equal(err, null, "auth-disabled deployment keeps the open-door contract");
});
// Design decision (owner 2026-06-19, shared with the policy layer's
// inferRequiredScope in src/server/authz/accessScopes.ts): /api/mcp sits in
// ADMIN_SCOPE_PREFIXES, so an oma_ access token needs `admin` regardless of
// acceptMcpConnectScope. The carve-out is an API-key-only feature — pinning
// it here so a future "fix" that routes oma_ tokens through the carve-out
// fails loudly.
test("oma_ access token with only mcp:connect scope is rejected (admin required by owner policy)", async () => {
await seedAuthRequired();
const token = accessTokensDb.createAccessToken({
name: "mcp-oma-test",
scope: MCP_CONNECT_SCOPE,
expiresAt: null,
});
const req = new Request("http://localhost:20128/api/mcp/stream", {
method: "POST",
headers: {
Authorization: `Bearer ${token.secret}`,
"Content-Type": "application/json",
},
});
const err = await requireManagementAuth(req, { acceptMcpConnectScope: true });
assert.ok(err !== null && err instanceof Response, "oma_ + mcp:connect must be rejected");
assert.equal(err.status, 403);
});
test("oma_ access token with admin scope passes the MCP guard", async () => {
await seedAuthRequired();
const token = accessTokensDb.createAccessToken({
name: "mcp-oma-admin",
scope: "admin",
expiresAt: null,
});
const req = new Request("http://localhost:20128/api/mcp/stream", {
method: "POST",
headers: {
Authorization: `Bearer ${token.secret}`,
"Content-Type": "application/json",
},
});
const err = await requireManagementAuth(req, { acceptMcpConnectScope: true });
assert.equal(err, null, "oma_ + admin must pass");
});