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
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,16 @@ Many other MCP-capable tools accept:

Configure these values wherever the tool expects MCP server settings.

## Tools (17 model-facing, plus 1 app-only helper)
## Tools (18 model-facing, plus 1 app-only helper)

Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows.

One additional Managed Auth helper (`begin_auth_login`) is marked app-only (`_meta.ui.visibility: ["app"]`); it refuses to execute on hosts that do not declare MCP Apps support. The App forwards the server-issued signed flow checkpoint to the shared `manage_auth_connections` `wait` action, so flow identity and terminal-state decisions stay on the server.

Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered.

Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools always advertise `project_id`: organization-wide connections must pass the selected project, while fixed-project connections may omit it or pass the matching ID. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it.

### manage\_\* tools

- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling.
Expand All @@ -280,6 +282,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_

### Standalone tools

- `get_connection_context` - Inspect the authenticated principal, organization, credential scope, and effective project scope.
- `computer_action` - Mouse, keyboard, clipboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, read_clipboard, write_clipboard, screenshot).
- `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack.
- `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle.
Expand All @@ -289,14 +292,12 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_

## Resources

- `browsers://` - List browser sessions
- `browser-pools://` - List browser pools
- `profiles://` - List browser profiles
- `apps://` - List deployed apps
- `browsers://{session_id}` - Access one browser session
- `browser-pools://{id_or_name}` - Access one browser pool
- `profiles://{profile_name}` - Access one browser profile
- `apps://{app_name}` - Access one deployed app
Project resources use the prefix `kernel://orgs/{organization_id}/projects/{project_id}`.

- `/browsers` and `/browsers/{session_id}` - List or access browser sessions
- `/browser-pools` and `/browser-pools/{id_or_name}` - List or access browser pools
- `/profiles` and `/profiles/{profile_name}` - List or access browser profiles
- `/apps` and `/apps/{app_name}` - List or access deployed apps

## Prompts

Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@mcp-ui/server": "^5.10.0",
"@modelcontextprotocol/sdk": "1.26.0",
"@onkernel/managed-auth-react": "0.4.1",
"@onkernel/sdk": "^0.85.0",
"@onkernel/sdk": "^0.87.0",
"@posthog/mcp": "0.10.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/redis": "^4.0.11",
Expand Down
187 changes: 116 additions & 71 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import {
import { verifyToken } from "@clerk/nextjs/server";
import { after, NextRequest } from "next/server";
import { isValidJwtFormat } from "@/lib/auth-utils";
import { flushMcpAnalytics, instrumentMcpAnalytics } from "@/lib/mcp/analytics";
import {
flushMcpAnalytics,
instrumentMcpAnalytics,
isMcpAnalyticsEnabled,
} from "@/lib/mcp/analytics";
import {
connectionAnalyticsFromContext,
resolveMcpConnectionContext,
} from "@/lib/mcp/auth-context";
import { mcpAppsAuthSubject } from "@/lib/mcp-apps-marker";
import { requestUsesMcpApps } from "@/lib/mcp-apps-request";
import {
Expand Down Expand Up @@ -51,21 +59,86 @@ function createAuthErrorResponse(
);
}

// The base tool set is unchanged. Capability negotiation only adds the
// Managed Auth launcher, its resource, and its app-only implementation tools.
// Handler variants keep per-connection capabilities out of tools/list unless
// the authenticated connection can use them.
const serverInfo = { serverInfo: { name, version } };
const handler = createMcpHandler((server) => {
instrumentMcpAnalytics(server);
registerMcpCapabilities(server);
}, serverInfo);
const mcpAppsHandler = createMcpHandler((server) => {
instrumentMcpAnalytics(server);
registerMcpCapabilities(server, { mcpApps: true });
}, serverInfo);
function createHandler({ mcpApps = false }: { mcpApps?: boolean } = {}) {
return createMcpHandler((server) => {
instrumentMcpAnalytics(server);
registerMcpCapabilities(server, { mcpApps });
}, serverInfo);
}

const handler = createHandler();
Comment thread
masnwilliams marked this conversation as resolved.
const mcpAppsHandler = createHandler({ mcpApps: true });

type AuthInfoExtra = {
userId: string | null;
clerkToken: string | null;
};

async function handleMcpRequestWithIdentity({
req,
token,
authSubject,
scopes,
authInfoExtra,
transportSessionId,
connectionContextCacheIdentity,
observeConnection,
}: {
req: NextRequest;
token: string;
authSubject: string;
scopes: string[];
authInfoExtra: AuthInfoExtra;
transportSessionId: string | null;
connectionContextCacheIdentity?: string;
observeConnection: boolean;
}) {
const [mcpApps, connectionContext] = await Promise.all([
requestUsesMcpApps(req, {
authSubject,
Comment thread
vercel[bot] marked this conversation as resolved.
transportSessionId,
ttlSeconds: 24 * 60 * 60,
}),
resolveMcpConnectionContext({
token,
signal: req.signal,
cacheIdentity: connectionContextCacheIdentity,
}),
]);
if (!connectionContext) {
throw new Error("Unable to resolve Kernel connection scope");
}
const connectionAnalytics =
observeConnection && isMcpAnalyticsEnabled()
? connectionAnalyticsFromContext(connectionContext)
: null;
const authHandler = withMcpAuth(
mcpApps ? mcpAppsHandler : handler,
async () => ({
token,
scopes,
clientId: "mcp-server",
extra: {
...authInfoExtra,
connectionContext,
connectionAnalytics,
},
}),
{
required: true,
resourceMetadataPath: "/.well-known/oauth-protected-resource/mcp",
},
);
return await authHandler(req);
}

async function handleAuthenticatedRequest(
req: NextRequest,
transportSessionId: string | null = null,
observeConnection = false,
): Promise<Response> {
const authHeader = req.headers.get("Authorization");
const token = authHeader?.startsWith("Bearer ")
Expand All @@ -80,81 +153,53 @@ async function handleAuthenticatedRequest(

if (!isValidJwtFormat(token)) {
// Opaque API keys are authenticated by the Kernel API rather than Clerk.
const authSubject = mcpAppsAuthSubject({ token });
const selectedHandler = (await requestUsesMcpApps(req, {
authSubject,
// Do not cache their context: /auth/context must revalidate the credential
// on every request so revoked keys cannot keep using a cached scope.
return await handleMcpRequestWithIdentity({
req,
token,
authSubject: mcpAppsAuthSubject({ token }),
scopes: ["apikey"],
authInfoExtra: { userId: null, clerkToken: null },
transportSessionId,
ttlSeconds: 24 * 60 * 60,
}))
? mcpAppsHandler
: handler;
const authHandler = withMcpAuth(
selectedHandler,
async () => ({
token,
scopes: ["apikey"],
clientId: "mcp-server",
extra: { userId: null, clerkToken: null },
}),
{
required: true,
resourceMetadataPath: "/.well-known/oauth-protected-resource/mcp",
},
);
return await authHandler(req);
observeConnection,
});
}

let userId: string;
try {
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY,
});

if (!payload.sub) {
return createAuthErrorResponse(
"invalid_token",
"Invalid token: No user ID found in token payload",
);
}

// Capability state is keyed only after Clerk verifies the JWT, and uses
// the verified user plus this signed MCP transport session.
const authSubject = mcpAppsAuthSubject({ token, userId: payload.sub });
const selectedHandler = (await requestUsesMcpApps(req, {
authSubject,
transportSessionId,
ttlSeconds: 24 * 60 * 60,
}))
? mcpAppsHandler
: handler;

// Create authenticated handler with auth info
const authHandler = withMcpAuth(
selectedHandler,
async (_req, _providedToken) => {
// Return auth info with validated user data
return {
token: token, // Use the validated token
scopes: ["openid"],
clientId: "mcp-server",
extra: {
userId: payload.sub,
clerkToken: token,
},
};
},
{
required: true,
resourceMetadataPath: "/.well-known/oauth-protected-resource/mcp",
},
);

return await authHandler(req);
userId = payload.sub;
} catch (authError) {
return createAuthErrorResponse(
"invalid_token",
`Invalid token: ${authError instanceof Error ? authError.message : "Authentication failed"}`,
);
}

// Capability state is keyed only after Clerk verifies the JWT, and uses
// the verified user plus this signed MCP transport session.
const authSubject = mcpAppsAuthSubject({ token, userId });
Comment thread
masnwilliams marked this conversation as resolved.
return await handleMcpRequestWithIdentity({
req,
token,
authSubject,
scopes: ["openid"],
authInfoExtra: { userId, clerkToken: token },
transportSessionId,
connectionContextCacheIdentity: transportSessionId
? `${authSubject}\0${transportSessionId}`
: undefined,
observeConnection,
});
}

export async function GET(req: NextRequest): Promise<Response> {
Expand Down Expand Up @@ -182,11 +227,10 @@ export async function POST(req: NextRequest): Promise<Response> {
} catch {
// Let the MCP transport return its normal parse error.
}
const initializeParams =
parsed?.method === "initialize" ? parsed.params : undefined;
const isInitialize = parsed?.method === "initialize";
const initializeParams = isInitialize ? parsed?.params : undefined;
const isStreamableInitialize =
new URL(req.url).pathname.endsWith("/mcp") &&
parsed?.method === "initialize";
new URL(req.url).pathname.endsWith("/mcp") && isInitialize;
const session = isStreamableInitialize
? createMcpTransportSession({
clientName: initializeParams?.clientInfo?.name,
Expand All @@ -208,6 +252,7 @@ export async function POST(req: NextRequest): Promise<Response> {
signal: req.signal,
}),
session?.id ?? null,
isInitialize,
);

if (!session) return response;
Expand Down
Loading
Loading