Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ Call `get_connection_context` before deciding whether to create or select a proj
- `manage_config_registry` - Look up current browser and proxy recommendations, start and inspect analyses, request cancellation, and list project configurations or analysis history.
- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
- `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan.
- `web_search` - Search the web, retrieve retained results, and inspect provider capabilities. Tool visibility uses a per-credential, per-connection Search entitlement snapshot cached for up to 30 minutes; the Search API remains authoritative for execution access. Search creation is billable and is not automatically retried.
- `manage_extensions` - List and delete uploaded browser extensions.
- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results.
- `manage_auth_connections` - Create, list, get, update, delete, login, submit, inspect timelines, and wait for managed-auth connections in every client. Supports health-check and automatic re-auth settings, managed-auth browser configuration, and canonical interaction-bound field/choice submissions. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too.
Expand Down
97 changes: 90 additions & 7 deletions src/app/[transport]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Kernel } from "@onkernel/sdk";
import type { McpConnectionScopeFailureAnalytics } from "@/lib/mcp/analytics";
import { defaultMcpDependencies } from "@/lib/mcp/dependencies";
import { clearMcpEntitlementsCacheForTests } from "@/lib/mcp/entitlements";
import { oauthResourceMetadata } from "@/lib/oauth-discovery";

process.env.CLERK_SECRET_KEY ??= "test-clerk-secret";
Expand Down Expand Up @@ -67,6 +68,7 @@ function failingAuthContext(error: unknown) {

beforeEach(() => {
captured.length = 0;
clearMcpEntitlementsCacheForTests();
});

afterEach(() => {
Expand Down Expand Up @@ -179,7 +181,7 @@ describe("connection scope failures through the handler", () => {
});
});

describe("vault entitlement routing", () => {
describe("capability routing", () => {
function installKernelResponses(entitlements: (token: string) => Response) {
const paths: string[] = [];
defaultMcpDependencies.createKernelClient = (token) =>
Expand All @@ -205,6 +207,11 @@ describe("vault entitlement routing", () => {
},
});
if (path === "/org/entitlements") return entitlements(token);
if (path === "/search/providers") return Response.json([]);
if (path === "/vaults")
return Response.json([], {
headers: { "x-has-more": "false", "x-next-offset": "0" },
});
throw new Error(`Unexpected API request: ${path}`);
},
});
Expand All @@ -229,13 +236,23 @@ describe("vault entitlement routing", () => {
return JSON.parse(event ? event.slice(6) : text);
}

test("selects tools per credential and rechecks access after revocation", async () => {
test("caches entitlement-gated tools per credential and MCP connection", async () => {
let enabled = true;
const paths = installKernelResponses((token) =>
Response.json({
features: { vaults: { enabled: token === "sk_allowed" && enabled } },
features: {
vaults: { enabled: token === "sk_allowed" && enabled },
search: { enabled: false },
},
}),
);
const unrelated = await call("tools/call", "sk_allowed", {
name: "get_connection_context",
arguments: {},
});
expect(unrelated.result.isError).not.toBe(true);
expect(paths).toEqual(["/auth/context"]);

const allowed = await call("tools/list");
expect(
allowed.result.tools.map((tool: { name: string }) => tool.name),
Expand All @@ -250,25 +267,91 @@ describe("vault entitlement routing", () => {
denied.result.tools.map((tool: { name: string }) => tool.name),
).toContain("manage_browsers");
enabled = false;
const revoked = await call("tools/call", "sk_allowed", {
const stillExposed = await call("tools/call", "sk_allowed", {
name: "manage_vaults",
arguments: { action: "list" },
});
expect(JSON.stringify(revoked)).toContain("not found");
expect(stillExposed.result.isError).not.toBe(true);
expect(paths).toEqual([
"/auth/context",
"/org/entitlements",
"/auth/context",
"/org/entitlements",
"/auth/context",
"/org/entitlements",
"/auth/context",
"/vaults",
]);
});

test("gates Search per connection and keeps other callers isolated", async () => {
let enabled = true;
const paths = installKernelResponses((token) =>
Response.json({
features: {
vaults: { enabled: true },
search: { enabled: token === "sk_allowed" && enabled },
},
}),
);
const allowed = await call("tools/list");
expect(
allowed.result.tools.map((tool: { name: string }) => tool.name),
).toContain("web_search");
const permittedCall = await call("tools/call", "sk_allowed", {
name: "web_search",
arguments: { action: "providers" },
});
expect(permittedCall.result.isError).not.toBe(true);
expect(JSON.parse(permittedCall.result.content[0].text)).toEqual([]);
const denied = await call("tools/list", "sk_denied");
expect(
denied.result.tools.map((tool: { name: string }) => tool.name),
).not.toContain("web_search");
expect(
denied.result.tools.map((tool: { name: string }) => tool.name),
).toContain("manage_vaults");
const deniedCall = await call("tools/call", "sk_denied", {
name: "web_search",
arguments: { action: "create", request: { query: "test" } },
});
expect(JSON.stringify(deniedCall)).toContain("not found");
enabled = false;
const sameConnection = await call("tools/list", "sk_allowed");
expect(
sameConnection.result.tools.map((tool: { name: string }) => tool.name),
).toContain("web_search");
expect(paths.filter((path) => path === "/org/entitlements")).toHaveLength(
2,
);
expect(paths.filter((path) => path === "/search/providers")).toHaveLength(
1,
);
expect(paths).not.toContain("/search");
});

test.each([200, 401, 403, 404, 429, 500, 503])(
"search fails closed without hiding unrelated tools (HTTP %s)",
async (status) => {
installKernelResponses(() =>
status === 200
? Response.json({ features: {} })
: Response.json({}, { status }),
);
const result = await call("tools/list");
const names = result.result.tools.map(
(tool: { name: string }) => tool.name,
);
expect(names).not.toContain("web_search");
expect(names).toContain("manage_browsers");
},
);

test.each([200, 404, 503])(
"keeps other tools available when entitlements are absent or fail (HTTP %s)",
async (status) => {
installKernelResponses(() => Response.json({ features: {} }, { status }));
installKernelResponses(() =>
Response.json({ features: { search: { enabled: true } } }, { status }),
);
const result = await call("tools/list");
expect(
result.result.tools.filter((tool: { name: string }) =>
Expand Down
53 changes: 49 additions & 4 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import {
createMcpTransportSession,
verifyMcpTransportSession,
} from "@/lib/mcp-transport-session";
import { registerMcpCapabilities } from "@/lib/mcp/register";
import { resolveMcpVaultAccess } from "@/lib/mcp/entitlements";
import {
mcpToolsetEnabledByConfig,
registerMcpCapabilities,
} from "@/lib/mcp/register";
import { resolveMcpEntitlements } from "@/lib/mcp/entitlements";
import { name, version } from "../../../server.json";

export async function OPTIONS(_req: NextRequest): Promise<Response> {
Expand All @@ -41,13 +44,40 @@ export async function OPTIONS(_req: NextRequest): Promise<Response> {
});
}

const ENTITLEMENT_GATED_TOOLS = new Set([
"web_search",
"manage_vaults",
"manage_vault_wallets",
"manage_vault_cards",
"manage_vault_credentials",
"manage_vault_items",
"manage_vault_provider_configs",
]);

const CORS_HEADERS = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};

async function requestRequiresMcpEntitlements(req: Request): Promise<boolean> {
if (req.method !== "POST") return false;
const payload = (await req
.clone()
.json()
.catch(() => null)) as {
method?: unknown;
params?: { name?: unknown };
} | null;
if (payload?.method === "tools/list") return true;
return (
payload?.method === "tools/call" &&
typeof payload.params?.name === "string" &&
ENTITLEMENT_GATED_TOOLS.has(payload.params.name)
);
}

function errorResponse(
status: number,
error: string,
Expand Down Expand Up @@ -114,6 +144,7 @@ const handler = createMcpHandler(({ authInfo }) => {
registerMcpCapabilities(server, {
mcpApps: authInfo?.extra?.mcpApps === true,
vaults: authInfo?.extra?.vaults === true,
search: authInfo?.extra?.search === true,
});
return server;
});
Expand Down Expand Up @@ -168,8 +199,21 @@ async function handleMcpRequestWithIdentity({
}
return connectionScopeFailureResponse(req, connection);
}
// Recheck with the current credential on every request, including tools/call.
const vaults = await resolveMcpVaultAccess({ token, signal: req.signal });
// Resolve entitlements only for tool discovery and gated tool calls.
const entitlements = (await requestRequiresMcpEntitlements(req))
? await resolveMcpEntitlements({
token,
signal: req.signal,
cacheIdentity: [
authSubject,
connection.context.authContext.organization.id,
transportSessionId ?? "stateless",
token,
].join("\0"),
})
: { vaults: false, search: false };
const { vaults } = entitlements;
const search = mcpToolsetEnabledByConfig("search") && entitlements.search;
const connectionContext = connection.context;
const connectionAnalytics =
observeConnection && isMcpAnalyticsEnabled()
Expand All @@ -184,6 +228,7 @@ async function handleMcpRequestWithIdentity({
...authInfoExtra,
mcpApps,
vaults,
search,
connectionContext,
connectionAnalytics,
},
Expand Down
Loading
Loading