diff --git a/.env.example b/.env.example index 1e55b48..09bcfbc 100644 --- a/.env.example +++ b/.env.example @@ -13,13 +13,17 @@ NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev # API Configuration - Only needed to set the Kernel SDK base url API_BASE_URL= +# Public origin of this MCP server. The Managed Auth MCP App connects only to +# the narrowly scoped same-origin relay. Local development: http://localhost:3002 +MANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com + # Mintlify API Configuration - Only needed for the search_docs tool call MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_ MINTLIFY_DOMAIN= # Optional MCP toolset gating. Comma-separated values. # Example: api_keys hides manage_api_keys so deployments can opt out of key management. -# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell +# Supported: apps, api_keys, auth_connections, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell # KERNEL_MCP_DISABLED_TOOLSETS=api_keys # Redis Configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f26374..acfcb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,20 @@ jobs: steps: - uses: actions/checkout@v4 + # Pin Bun: the managed-auth App bundle check is byte-exact and Bun's + # minifier output can change between releases. Regenerate the bundle + # with this exact version when bumping it. - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.3" - run: bun install --frozen-lockfile + - name: Check managed-auth App bundle + run: bun run check:managed-auth-app + - name: Type check - run: bunx tsc --noEmit + run: bunx tsc --noEmit --incremental false - name: Test run: bun test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..231db60 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/lib/mcp/apps/generated/managed-auth-app.ts diff --git a/README.md b/README.md index 6529328..de77080 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,11 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (16 total) +## Tools (17 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. Five standalone tools handle high-frequency workflows. +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. @@ -272,7 +274,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `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. - `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, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections. +- `manage_auth_connections` - Create, list, get, delete, login, submit, and wait for managed-auth connections in every client. Use domain-filtered `list` for discovery. App-capable clients additionally receive `open_auth_login`; the programmatic actions remain available there too. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. @@ -283,6 +285,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. +- `open_auth_login` - Open a secure interactive Managed Auth MCP App after user consent. Registered only for clients that declare MCP Apps support; credentials and MFA never enter MCP/model traffic. ## Resources @@ -327,6 +330,19 @@ Assistant: I'll create a browser session, then execute Playwright code against i Returns: { success: true, result: "Example Domain" } ``` +### Use managed authentication for a protected site + +1. Call `manage_auth_connections` with `action: "list"` and the exact `domain_filter`. +2. Fetch all pages. Reuse an authenticated connection; ask only when multiple relevant accounts match. +3. A direct request to log in is consent. If authentication is discovered incidentally, ask before opening the App. +4. For a new connection, choose a concise service-derived profile name unless the user supplied one; do not ask solely for a profile name. +5. Call `open_auth_login`, then immediately follow its `next_action` and repeat the read-only wait while it reports `pending`. +6. The user enters credentials/MFA only in the secure App. Once the wait reports `authenticated`, resume the original task with the verified `profile_name`. + +Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. + +The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. The programmatic `manage_auth_connections` create/login actions preserve the API’s opt-in and inheritance behavior when these parameters are omitted. + ### Set up browser profiles for authentication ``` diff --git a/bun.lock b/bun.lock index 7db18e2..7928779 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,8 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.78.0", + "@onkernel/managed-auth-react": "0.4.1", + "@onkernel/sdk": "^0.85.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -37,6 +38,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "bun": "1.3.3", "bun-types": "^1.3.14", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", @@ -148,7 +150,31 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.78.0", "", {}, "sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw=="], + "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.4.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-8p+pMljBRQMKLiBFWbyJQ2ohYflsomWYGgQtlF2sbb4b2w/z+CBsnxUiBs1q23h/W1OtHsbw/jKGX51ZCjNYzA=="], + + "@onkernel/sdk": ["@onkernel/sdk@0.85.0", "", {}, "sha512-u5EKb2itzuUV5Yq+AQ1mJ1xsnsXgvNrEUrg40KjVw9GWa+deE5jCsVtBrQaGF8Ysbx//QkG0k0jyAZxgcsfDNA=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-xGDePueVFrNgkS+iN0QdEFeRrx2MQ5hQ9ipRFu7N73rgoSSJsFlOKKt2uGZzunczedViIfjYl0ii0K4E9aZ0Ow=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ij4wQ9ECLFf1XFry+IFUN+28if40ozDqq6+QtuyOhIwraKzXOlAUbILhRMGvM3ED3yBex2mTwlKpA4Vja/V2g=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-DabZ3Mt1XcJneWdEEug8l7bCPVvDBRBpjUIpNnRnMFWFnzr8KBEpMcaWTwYOghjXyJdhB4MPKb19MwqyQ+FHAw=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-XWQ3tV/gtZj0wn2AdSUq/tEOKWT4OY+Uww70EbODgrrq00jxuTfq5nnYP6rkLD0M/T5BHJdQRSfQYdIni9vldw=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-7eIARtKZKZDtah1aCpQUj/1/zT/zHRR063J6oAxZP9AuA547j5B9OM2D/vi/F4En7Gjk9FPjgPGTSYeqpQDzJw=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-IU8pxhIf845psOv55LqJyL+tSUc6HHMfs6FGhuJcAnyi92j+B1HjOhnFQh9MW4vjoo7do5F8AerXlvk59RGH2w=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-xNSDRPn1yyObKteS8fyQogwsS4eCECswHHgaKM+/d4wy/omZQrXn8ZyGm/ZF9B73UfQytUfbhE7nEnrFq03f0w=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JoRTPdAXRkNYouUlJqEncMWUKn/3DiWP03A7weBbtbsKr787gcdNna2YeyQKCb1lIXE4v1k18RM3gaOpQobGIQ=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-kWqa1LKvDdAIzyfHxo3zGz3HFWbFHDlrNK77hKjUN42ycikvZJ+SHSX76+1OW4G8wmLETX4Jj+4BM1y01DQRIQ=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-u5eZHKq6TPJSE282KyBOicGQ2trkFml0RoUfqkPOJVo7TXGrsGYYzdsugZRnVQY/WEmnxGtBy4T3PAaPqgQViA=="], "@posthog/core": ["@posthog/core@1.45.2", "", { "dependencies": { "@posthog/types": "^1.398.0" } }, "sha512-OhEHkojFkqEFbtm/wUtLYgomN1gFNU9IyufvNsuZvpIOh8TZ9tnAvI81Sej/2zu+vyDExs9JroQB5SW3y5QyOw=="], @@ -230,6 +256,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun": ["bun@1.3.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.3", "@oven/bun-darwin-x64": "1.3.3", "@oven/bun-darwin-x64-baseline": "1.3.3", "@oven/bun-linux-aarch64": "1.3.3", "@oven/bun-linux-aarch64-musl": "1.3.3", "@oven/bun-linux-x64": "1.3.3", "@oven/bun-linux-x64-baseline": "1.3.3", "@oven/bun-linux-x64-musl": "1.3.3", "@oven/bun-linux-x64-musl-baseline": "1.3.3", "@oven/bun-windows-x64": "1.3.3", "@oven/bun-windows-x64-baseline": "1.3.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -246,6 +274,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], diff --git a/package.json b/package.json index 77aa08b..978a0fc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ }, "scripts": { "dev": "next dev -p 3002", - "build": "next build", + "build:managed-auth-app": "bun scripts/build-managed-auth-app.mjs", + "check:managed-auth-app": "bun scripts/build-managed-auth-app.mjs --check", + "build": "bun run check:managed-auth-app && next build", "start": "next start -p 3002", "lint": "next lint", "test": "bun test", @@ -35,7 +37,8 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.78.0", + "@onkernel/managed-auth-react": "0.4.1", + "@onkernel/sdk": "^0.85.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -62,6 +65,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "bun": "1.3.3", "bun-types": "^1.3.14", "postcss": "^8.5.6", "tailwindcss": "^4.1.11" diff --git a/scripts/build-managed-auth-app.mjs b/scripts/build-managed-auth-app.mjs new file mode 100644 index 0000000..63cfce6 --- /dev/null +++ b/scripts/build-managed-auth-app.mjs @@ -0,0 +1,98 @@ +// Builds the managed-auth MCP App into a single self-contained HTML bundle. +// The --check mode is byte-exact, and Bun's minifier output can change between +// releases, so the bundle is only reproducible with the exact Bun dependency +// pinned in package.json and matched by CI (currently 1.3.3). Production build +// checks the committed artifact; regeneration remains an explicit command: +// bun run build:managed-auth-app +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const EXPECTED_BUN_VERSION = "1.3.3"; +if (Bun.version !== EXPECTED_BUN_VERSION) { + throw new Error( + `Managed-auth App bundling requires Bun ${EXPECTED_BUN_VERSION}; got ${Bun.version}. Run bun install and use the project-local bun binary.`, + ); +} + +const root = resolve(import.meta.dirname, ".."); +const entrypoint = join(root, "src/lib/mcp/apps/managed-auth-entry.tsx"); +const generatedPath = join( + root, + "src/lib/mcp/apps/generated/managed-auth-app.ts", +); +const check = process.argv.includes("--check"); +const temp = await mkdtemp(join(tmpdir(), "kernel-managed-auth-app-")); + +try { + const build = await Bun.build({ + entrypoints: [entrypoint], + outdir: temp, + target: "browser", + format: "esm", + minify: true, + splitting: false, + sourcemap: "none", + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + }); + + if (!build.success) { + for (const log of build.logs) console.error(log); + process.exitCode = 1; + } else { + let javascript = ""; + let css = ""; + for (const output of build.outputs) { + if (output.path.endsWith(".js")) javascript += await output.text(); + if (output.path.endsWith(".css")) css += await output.text(); + } + if (!javascript) + throw new Error("Bun did not emit managed-auth JavaScript"); + + const escapeScript = (value) => value.replaceAll(" value.replaceAll(" + + + + +Kernel Managed Authentication + + +
+ + +`; + const generated = `// Generated by scripts/build-managed-auth-app.mjs. Do not edit.\nexport const MANAGED_AUTH_APP_HTML = ${JSON.stringify(html)};\n`; + + if (check) { + let current = ""; + try { + current = await readFile(generatedPath, "utf8"); + } catch { + // Report the same actionable stale-bundle error below. + } + if (current !== generated) { + console.error( + "Managed-auth App bundle is stale. Run: bun run build:managed-auth-app", + ); + process.exitCode = 1; + } + } else { + await mkdir(resolve(generatedPath, ".."), { recursive: true }); + await writeFile(generatedPath, generated); + console.log(`Generated ${generatedPath}`); + } + } +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 1c7faac..87371e0 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -6,11 +6,13 @@ 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 { mcpAppsAuthSubject } from "@/lib/mcp-apps-marker"; +import { requestUsesMcpApps } from "@/lib/mcp-apps-request"; import { - flushMcpAnalytics, - instrumentMcpAnalytics, - mintMcpSessionId, -} from "@/lib/mcp/analytics"; + createMcpTransportSession, + verifyMcpTransportSession, +} from "@/lib/mcp-transport-session"; import { registerMcpCapabilities } from "@/lib/mcp/register"; import { name, version } from "../../../server.json"; @@ -49,18 +51,22 @@ function createAuthErrorResponse( ); } -// Create MCP handler with tools -const handler = createMcpHandler( - (server) => { - instrumentMcpAnalytics(server); - registerMcpCapabilities(server); - }, - // Identity returned on initialize. Taken from server.json so the handshake and the - // registry entry can't disagree; without it mcp-handler advertises its own default. - { serverInfo: { name, version } }, -); +// The base tool set is unchanged. Capability negotiation only adds the +// Managed Auth launcher, its resource, and its app-only implementation tools. +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); -async function handleAuthenticatedRequest(req: NextRequest): Promise { +async function handleAuthenticatedRequest( + req: NextRequest, + transportSessionId: string | null = null, +): Promise { const authHeader = req.headers.get("Authorization"); const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7).trim() @@ -73,8 +79,17 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } 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, + transportSessionId, + ttlSeconds: 24 * 60 * 60, + })) + ? mcpAppsHandler + : handler; const authHandler = withMcpAuth( - handler, + selectedHandler, async () => ({ token, scopes: ["apikey"], @@ -101,9 +116,20 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { ); } + // 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( - handler, + selectedHandler, async (_req, _providedToken) => { // Return auth info with validated user data return { @@ -139,26 +165,56 @@ export async function GET(req: NextRequest): Promise { export async function POST(req: NextRequest): Promise { after(flushMcpAnalytics); - const sessionId = await mintMcpSessionId(req); - if (!sessionId) return await handleAuthenticatedRequest(req); + const body = await req.text(); + type InitializeRequest = { + method?: unknown; + params?: { + clientInfo?: { name?: string; version?: string }; + protocolVersion?: string; + }; + }; + let parsed: InitializeRequest | null = null; + try { + const value = JSON.parse(body) as unknown; + if (value && typeof value === "object" && !Array.isArray(value)) { + parsed = value as InitializeRequest; + } + } catch { + // Let the MCP transport return its normal parse error. + } + const initializeParams = + parsed?.method === "initialize" ? parsed.params : undefined; + const isStreamableInitialize = + new URL(req.url).pathname.endsWith("/mcp") && + parsed?.method === "initialize"; + const session = isStreamableInitialize + ? createMcpTransportSession({ + clientName: initializeParams?.clientInfo?.name, + clientVersion: initializeParams?.clientInfo?.version, + protocolVersion: initializeParams?.protocolVersion, + }) + : verifyMcpTransportSession(req.headers.get(MCP_SESSION_HEADER)); - // Pass the token in on the handshake too, so the initialize event lands in the same - // session as the calls that follow it. + // Only the verified inner token reaches PostHog's instrumentation. Clients + // receive and replay the signed outer token, which capability storage trusts. const requestHeaders = new Headers(req.headers); - requestHeaders.set(MCP_SESSION_HEADER, sessionId); + if (session) requestHeaders.set(MCP_SESSION_HEADER, session.analyticsToken); + else requestHeaders.delete(MCP_SESSION_HEADER); const response = await handleAuthenticatedRequest( new NextRequest(req.url, { method: req.method, headers: requestHeaders, - body: await req.text(), + body, signal: req.signal, }), + session?.id ?? null, ); + if (!session) return response; const headers = new Headers(response.headers); - headers.set(MCP_SESSION_HEADER, sessionId); - // Preflight allows the header; a browser only gets to read it if the response that - // carries it says so too. + if (isStreamableInitialize || headers.has(MCP_SESSION_HEADER)) { + headers.set(MCP_SESSION_HEADER, session.token); + } headers.set("Access-Control-Expose-Headers", MCP_SESSION_HEADER); return new Response(response.body, { status: response.status, diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts new file mode 100644 index 0000000..a0ea8bf --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from "bun:test"; +import { proxyManagedAuthRequest } from "./route"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + btoa(JSON.stringify(value)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +const scopedToken = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 4102444800, +}); + +function request( + path: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {}, +) { + return new Request(`http://localhost:3002${path}`, { + method: options.method ?? "GET", + headers: options.headers, + body: options.body, + }); +} + +function expectCors(response: Response) { + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe( + "GET, POST, OPTIONS", + ); +} + +describe("managed-auth relay", () => { + test("rejects invalid paths, methods, query parameters, and API keys", async () => { + const invalidPath = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/arbitrary"), + ["c_1", "arbitrary"], + ); + expect(invalidPath.status).toBe(404); + expectCors(invalidPath); + + let traversalForwarded = false; + const traversal = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/%2e%2e/exchange", { + method: "POST", + body: "{}", + }), + ["..", "exchange"], + (async () => { + traversalForwarded = true; + return new Response(null); + }) as unknown as typeof fetch, + ); + expect(traversal.status).toBe(404); + expect(traversalForwarded).toBe(false); + + const invalidMethod = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "POST", + }), + ["c_1", "events"], + ); + expect(invalidMethod.status).toBe(405); + expectCors(invalidMethod); + + const query = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1?upstream=evil"), + ["c_1"], + ); + expect(query.status).toBe(400); + + const apiKey = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: "Bearer sk_not_a_managed_auth_jwt" }, + }), + ["c_1"], + ); + expect(apiKey.status).toBe(401); + expectCors(apiKey); + }); + + test("rejects expired scoped JWTs at the relay boundary", async () => { + const expired = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 1700000000, // 2023-11-14, in the past + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: `Bearer ${expired}` }, + }), + ["c_1"], + ); + expect(response.status).toBe(401); + expectCors(response); + }); + + test("allows unauthenticated exchange and strips cookies and arbitrary headers", async () => { + let forwarded: RequestInit | undefined; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwarded = init; + return new Response('{"jwt":"scoped-secret"}', { + headers: { "content-type": "application/json", "set-cookie": "bad=1" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { + cookie: "mcp=secret", + "x-forwarded-for": "127.0.0.1", + "x-arbitrary": "do-not-forward", + "content-type": "application/json", + accept: "application/json", + }, + body: '{"code":"handoff-secret"}', + }), + ["c_1", "exchange"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expectCors(response); + const headers = new Headers(forwarded?.headers); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("x-forwarded-for")).toBeNull(); + expect(headers.get("x-arbitrary")).toBeNull(); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + test("passes scoped JWT only to fixed authenticated endpoints", async () => { + let forwardedAuthorization = ""; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwardedAuthorization = + new Headers(init?.headers).get("authorization") ?? ""; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/submit", { + method: "POST", + headers: { + authorization: `Bearer ${scopedToken}`, + "content-type": "application/json", + }, + body: "{}", + }), + ["c_1", "submit"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expect(forwardedAuthorization).toBe(`Bearer ${scopedToken}`); + }); + + test("preserves unbuffered SSE and CORS preflight", async () => { + const upstream = async () => + new Response("event: status\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + headers: { authorization: `Bearer ${scopedToken}` }, + }), + ["c_1", "events"], + upstream as unknown as typeof fetch, + ); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("cache-control")).toBe( + "no-cache, no-transform", + ); + expect(response.headers.get("x-accel-buffering")).toBe("no"); + expect(response.headers.get("content-encoding")).toBe("identity"); + expectCors(response); + + const preflight = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "OPTIONS", + }), + ["c_1", "events"], + ); + expect(preflight.status).toBe(204); + expectCors(preflight); + }); + + test("enforces the request body limit and does not log secrets", async () => { + const tooLarge = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { "content-length": String(65 * 1024) }, + body: "x", + }), + ["c_1", "exchange"], + ); + expect(tooLarge.status).toBe(413); + expectCors(tooLarge); + + let canceled = false; + const chunkedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { + canceled = true; + }, + }); + const chunked = await proxyManagedAuthRequest( + new Request( + "http://localhost:3002/managed-auth-proxy/auth/connections/c_1/exchange", + { method: "POST", body: chunkedBody }, + ), + ["c_1", "exchange"], + ); + expect(chunked.status).toBe(413); + expect(canceled).toBe(true); + + const calls: unknown[][] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => calls.push(args); + console.error = (...args) => calls.push(args); + try { + await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + body: '{"code":"never-log-this"}', + }), + ["c_1", "exchange"], + (async () => + new Response( + '{"jwt":"never-log-this-either"}', + )) as unknown as typeof fetch, + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + expect(calls).toHaveLength(0); + }); +}); diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts new file mode 100644 index 0000000..20407f3 --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts @@ -0,0 +1,214 @@ +const MAX_BODY_BYTES = 64 * 1024; +const ALLOWED_REQUEST_HEADERS = ["authorization", "content-type", "accept"]; +const ALLOWED_METHODS = "GET, POST, OPTIONS"; + +type RouteContext = { params: Promise<{ path: string[] }> }; +type FetchLike = typeof fetch; + +function corsHeaders(cacheControl = "private, no-store") { + return new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": ALLOWED_METHODS, + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept", + "Cache-Control": cacheControl, + }); +} + +function responseError(status: number, message: string) { + return new Response(message, { status, headers: corsHeaders() }); +} + +function validConnectionId(value: string | undefined): value is string { + // URL resolves literal and percent-encoded dot segments before fetching. + // Reject them explicitly so the connection ID can never escape the fixed + // /auth/connections/ upstream prefix. + return !!value && value !== "." && value !== ".."; +} + +function validOperation(path: string[], method: string): boolean { + if (path.length === 1 && method === "GET") { + return validConnectionId(path[0]); + } + if (path.length !== 2 || !validConnectionId(path[0]) || !path[1]) { + return false; + } + if (method === "POST") { + return path[1] === "exchange" || path[1] === "submit"; + } + return method === "GET" && path[1] === "events"; +} + +function pathExists(path: string[]): boolean { + return ( + (path.length === 1 && validConnectionId(path[0])) || + (path.length === 2 && + validConnectionId(path[0]) && + ["exchange", "submit", "events"].includes(path[1])) + ); +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (normalized.length % 4)) % 4); + return JSON.parse(atob(normalized + padding)) as Record; + } catch { + return null; + } +} + +function managedAuthAuthorization(request: Request): string | null { + const authorization = request.headers.get("authorization"); + const match = authorization?.match(/^Bearer\s+([^\s]+)$/i); + if (!match) return null; + const claims = decodeJwtPayload(match[1]); + if ( + claims?.iss !== "kernel-api" || + typeof claims.managed_auth_session_id !== "string" || + !claims.managed_auth_session_id || + typeof claims.exp !== "number" || + // Reject expired session JWTs at the relay boundary instead of + // forwarding them upstream. + claims.exp * 1000 <= Date.now() + ) { + return null; + } + return authorization; +} + +async function readSmallBody(request: Request): Promise { + const length = request.headers.get("content-length"); + if (length) { + const parsedLength = Number(length); + if ( + !Number.isSafeInteger(parsedLength) || + parsedLength < 0 || + parsedLength > MAX_BODY_BYTES + ) { + await request.body?.cancel(); + return null; + } + } + + if (!request.body) return new ArrayBuffer(0); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body.buffer; +} + +export async function proxyManagedAuthRequest( + request: Request, + path: string[], + fetchUpstream: FetchLike = fetch, +): Promise { + if (new URL(request.url).search) { + return responseError(400, "Query parameters are not allowed"); + } + + if (request.method === "OPTIONS") { + return pathExists(path) + ? new Response(null, { status: 204, headers: corsHeaders() }) + : responseError(404, "Not found"); + } + + if (!pathExists(path)) return responseError(404, "Not found"); + if (!validOperation(path, request.method)) { + return responseError(405, "Method not allowed"); + } + + const isExchange = path.length === 2 && path[1] === "exchange"; + const authorization = isExchange ? null : managedAuthAuthorization(request); + if (!isExchange && !authorization) { + return responseError(401, "Invalid managed-auth authorization"); + } + + let body: ArrayBuffer | undefined; + if (request.method === "POST") { + const requestBody = await readSmallBody(request); + if (requestBody === null) { + return responseError(413, "Request body too large"); + } + body = requestBody; + } + + const upstreamHeaders = new Headers(); + for (const name of ALLOWED_REQUEST_HEADERS) { + if (name === "authorization") continue; + const value = request.headers.get(name); + if (value) upstreamHeaders.set(name, value); + } + if (authorization) upstreamHeaders.set("authorization", authorization); + + const baseUrl = process.env.API_BASE_URL ?? "https://api.onkernel.com"; + const upstreamUrl = new URL( + `/auth/connections/${path.map(encodeURIComponent).join("/")}`, + baseUrl, + ); + const upstream = await fetchUpstream(upstreamUrl, { + method: request.method, + headers: upstreamHeaders, + ...(body && { body }), + redirect: "manual", + }); + + if (upstream.status >= 300 && upstream.status < 400) { + return responseError(502, "Upstream redirect rejected"); + } + + const isEvents = path.length === 2 && path[1] === "events"; + const headers = corsHeaders( + isEvents ? "no-cache, no-transform" : "private, no-store", + ); + const contentType = upstream.headers.get("content-type"); + if (isEvents) { + headers.set("Content-Type", "text/event-stream"); + headers.set("X-Accel-Buffering", "no"); + headers.set("Content-Encoding", "identity"); + } else if (contentType) { + headers.set("Content-Type", contentType); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + +async function handle(request: Request, context: RouteContext) { + const { path } = await context.params; + try { + return await proxyManagedAuthRequest(request, path); + } catch { + return responseError(502, "Managed-auth relay unavailable"); + } +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const OPTIONS = handle; +export const DELETE = handle; +export const PATCH = handle; +export const PUT = handle; diff --git a/src/lib/mcp-apps-marker.test.ts b/src/lib/mcp-apps-marker.test.ts new file mode 100644 index 0000000..8980ca9 --- /dev/null +++ b/src/lib/mcp-apps-marker.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { mcpAppsAuthSubject, mcpAppsMarkerKey } from "@/lib/mcp-apps-marker"; + +process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; + +describe("MCP Apps marker identity", () => { + test("OAuth refresh keeps the authenticated user subject", () => { + const first = mcpAppsAuthSubject({ token: "jwt_1", userId: "user_1" }); + const refreshed = mcpAppsAuthSubject({ + token: "jwt_2", + userId: "user_1", + }); + expect(first).toBe(refreshed); + }); + + test("opposite-capability clients sharing one Clerk user stay isolated", () => { + const subject = mcpAppsAuthSubject({ token: "jwt", userId: "user_1" }); + const appsClient = mcpAppsMarkerKey(subject, "mcp_session_apps"); + const plainClient = mcpAppsMarkerKey(subject, "mcp_session_plain"); + expect(appsClient).not.toBe(plainClient); + }); + + test("opposite-capability clients sharing one API key stay isolated", () => { + const subject = mcpAppsAuthSubject({ token: "sk_shared" }); + const appsClient = mcpAppsMarkerKey(subject, "mcp_session_apps"); + const plainClient = mcpAppsMarkerKey(subject, "mcp_session_plain"); + expect(appsClient).not.toBe(plainClient); + expect(mcpAppsAuthSubject({ token: "sk_shared" })).toBe(subject); + }); + + test("does not expose credentials or user ids in Redis keys", () => { + const subject = mcpAppsAuthSubject({ + token: "sk_secret_value", + userId: "user_sensitive", + }); + const key = mcpAppsMarkerKey(subject, "session_sensitive"); + expect(key).not.toContain("sk_secret_value"); + expect(key).not.toContain("user_sensitive"); + expect(key).not.toContain("session_sensitive"); + }); +}); diff --git a/src/lib/mcp-apps-marker.ts b/src/lib/mcp-apps-marker.ts new file mode 100644 index 0000000..4f97d5c --- /dev/null +++ b/src/lib/mcp-apps-marker.ts @@ -0,0 +1,40 @@ +import { createHmac } from "node:crypto"; + +function markerSecret(): string { + const key = process.env.CLERK_SECRET_KEY; + if (!key) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return key; +} + +function hash(value: string): string { + return createHmac("sha256", markerSecret()).update(value).digest("hex"); +} + +/** + * Stable authenticated subject for capability storage. Clerk callers use the + * verified user id so access-token refresh is harmless. Opaque API keys use an + * HMAC so the credential itself never enters Redis keys. + */ +export function mcpAppsAuthSubject({ + token, + userId, +}: { + token: string; + userId?: string | null; +}): string { + return userId ? `user:${hash(userId)}` : `apikey:${hash(token)}`; +} + +/** + * Capability state belongs to one authenticated subject *and* one signed MCP + * transport session. Clients sharing a Clerk user or API key therefore cannot + * expose or clear each other's App-only tools. + */ +export function mcpAppsMarkerKey( + authSubject: string, + transportSessionId: string, +): string { + return `mcp-apps:${hash(`${authSubject}\0${transportSessionId}`)}`; +} diff --git a/src/lib/mcp-apps-request.test.ts b/src/lib/mcp-apps-request.test.ts new file mode 100644 index 0000000..bd1fb57 --- /dev/null +++ b/src/lib/mcp-apps-request.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { + requestUsesMcpApps, + type McpAppsMarkerStore, +} from "@/lib/mcp-apps-request"; + +function request(method: string, params: Record = {}) { + return new Request("https://mcp.example/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); +} + +function memoryStore() { + const markers = new Set(); + const key = (value: { authSubject: string; transportSessionId: string }) => + `${value.authSubject}:${value.transportSessionId}`; + const store: McpAppsMarkerStore = { + mark: async (value) => { + markers.add(key(value)); + }, + clear: async (value) => { + markers.delete(key(value)); + }, + has: async (value) => markers.has(key(value)), + }; + return { store, markers }; +} + +const appsInitialize = () => + request("initialize", { + capabilities: { + extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [] } }, + }, + }); +const plainInitialize = () => request("initialize", { capabilities: {} }); +const toolsList = () => request("tools/list"); + +async function assertOppositeCapabilitiesStayIsolated(authSubject: string) { + const { store, markers } = memoryStore(); + const apps = { + authSubject, + transportSessionId: "session_apps", + ttlSeconds: 300, + }; + const plain = { + authSubject, + transportSessionId: "session_plain", + ttlSeconds: 300, + }; + + await requestUsesMcpApps(appsInitialize(), apps, store); + await requestUsesMcpApps(plainInitialize(), plain, store); + expect(await requestUsesMcpApps(toolsList(), apps, store)).toBe(true); + expect(await requestUsesMcpApps(toolsList(), plain, store)).toBe(false); + expect(markers.size).toBe(1); + + // Re-initializing the plain client cannot clear the Apps client's marker. + await requestUsesMcpApps(plainInitialize(), plain, store); + expect(await requestUsesMcpApps(toolsList(), apps, store)).toBe(true); +} + +describe("streamable-HTTP MCP Apps capability lifecycle", () => { + test("isolates opposite-capability clients sharing one Clerk subject", async () => { + await assertOppositeCapabilitiesStayIsolated("user:shared"); + }); + + test("isolates opposite-capability clients sharing one API key subject", async () => { + await assertOppositeCapabilitiesStayIsolated("apikey:shared"); + }); + + test("fails closed without a signed transport-session identity", async () => { + const { store, markers } = memoryStore(); + const identity = { + authSubject: "user:shared", + transportSessionId: null, + ttlSeconds: 300, + }; + await requestUsesMcpApps(appsInitialize(), identity, store); + expect(await requestUsesMcpApps(toolsList(), identity, store)).toBe(false); + expect(markers.size).toBe(0); + }); + + test("does not accept a mixed initialize batch", async () => { + const { store, markers } = memoryStore(); + const batch = new Request("https://mcp.example/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + capabilities: { + extensions: { "io.modelcontextprotocol/ui": {} }, + }, + }, + }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + ]), + }); + const identity = { + authSubject: "user:shared", + transportSessionId: "session_batch", + ttlSeconds: 300, + }; + expect(await requestUsesMcpApps(batch, identity, store)).toBe(false); + expect(markers.size).toBe(0); + }); +}); diff --git a/src/lib/mcp-apps-request.ts b/src/lib/mcp-apps-request.ts new file mode 100644 index 0000000..a83fbda --- /dev/null +++ b/src/lib/mcp-apps-request.ts @@ -0,0 +1,89 @@ +import { + clearMcpAppsClient, + hasMcpAppsClient, + markMcpAppsClient, +} from "@/lib/redis"; +import { initializeDeclaresMcpApps } from "@/lib/mcp/tools/mcp-apps-gate"; + +export type McpAppsMarkerStore = { + mark: typeof markMcpAppsClient; + clear: typeof clearMcpAppsClient; + has: typeof hasMcpAppsClient; +}; + +const redisMarkerStore: McpAppsMarkerStore = { + mark: markMcpAppsClient, + clear: clearMcpAppsClient, + has: hasMcpAppsClient, +}; + +/** + * Records initialize capability and selects App registration for later + * streamable-HTTP requests. Identity is supplied only after bearer auth and + * combines the authenticated subject with the signed transport session. + */ +export async function requestUsesMcpApps( + req: Request, + identity: { + authSubject: string; + transportSessionId: string | null; + ttlSeconds: number; + }, + store: McpAppsMarkerStore = redisMarkerStore, +): Promise { + if (req.method !== "POST") return false; + let body: unknown; + try { + body = await req.clone().json(); + } catch { + return false; + } + + const request = + body && typeof body === "object" && !Array.isArray(body) + ? (body as { + method?: unknown; + params?: { name?: unknown; uri?: unknown }; + }) + : null; + if (request?.method === "initialize") { + try { + if (identity.transportSessionId) { + const marker = { + authSubject: identity.authSubject, + transportSessionId: identity.transportSessionId, + }; + if (initializeDeclaresMcpApps(body)) { + await store.mark({ ...marker, ttlSeconds: identity.ttlSeconds }); + } else { + await store.clear(marker); + } + } + } catch (error) { + console.error("Failed to record MCP Apps capability:", error); + } + return false; + } + + const needsAppRegistration = + request?.method === "tools/list" || + request?.method === "resources/list" || + (request?.method === "resources/read" && + typeof request.params?.uri === "string" && + request.params.uri.startsWith("ui://kernel/managed-auth-login")) || + (request?.method === "tools/call" && + (request.params?.name === "open_auth_login" || + request.params?.name === "begin_auth_login")); + if (!needsAppRegistration || !identity.transportSessionId) return false; + + try { + return await store.has({ + authSubject: identity.authSubject, + transportSessionId: identity.transportSessionId, + ttlSeconds: identity.ttlSeconds, + }); + } catch (error) { + console.error("MCP Apps capability check failed; using base tools:", error); + return false; + } +} diff --git a/src/lib/mcp-transport-session.test.ts b/src/lib/mcp-transport-session.test.ts new file mode 100644 index 0000000..bbfc348 --- /dev/null +++ b/src/lib/mcp-transport-session.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + createMcpTransportSession, + verifyMcpTransportSession, +} from "@/lib/mcp-transport-session"; + +process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; + +describe("signed MCP transport sessions", () => { + test("round-trips the server-issued session identity", () => { + const created = createMcpTransportSession({ + clientName: "apps-client", + clientVersion: "1.0.0", + protocolVersion: "2025-11-25", + }); + const verified = verifyMcpTransportSession(created.token); + expect(verified?.id).toBe(created.id); + expect(verified?.analyticsToken).toBe(created.analyticsToken); + }); + + test("rejects a client-tampered transport session", () => { + const created = createMcpTransportSession(); + const [version, payload, signature] = created.token.split("."); + const replacement = payload.endsWith("A") + ? `${payload.slice(0, -1)}B` + : `${payload.slice(0, -1)}A`; + expect( + verifyMcpTransportSession(`${version}.${replacement}.${signature}`), + ).toBeNull(); + }); + + test("mints distinct identities for clients sharing credentials", () => { + const apps = createMcpTransportSession({ clientName: "apps" }); + const plain = createMcpTransportSession({ clientName: "plain" }); + expect(apps.id).not.toBe(plain.id); + expect(apps.token).not.toBe(plain.token); + }); +}); diff --git a/src/lib/mcp-transport-session.ts b/src/lib/mcp-transport-session.ts new file mode 100644 index 0000000..8dc7342 --- /dev/null +++ b/src/lib/mcp-transport-session.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { + decodeSessionId, + encodeSessionId, + newSessionId, + type SessionTokenPayload, +} from "@posthog/mcp"; + +const TOKEN_VERSION = "v1"; + +function signingKey(): string { + const key = process.env.CLERK_SECRET_KEY; + if (!key) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return key; +} + +function signature(value: string): string { + return createHmac("sha256", signingKey()).update(value).digest("base64url"); +} + +export type McpTransportSession = { + id: string; + /** Signed value returned to and replayed by the MCP client. */ + token: string; + /** PostHog-compatible value passed only to the in-process MCP handler. */ + analyticsToken: string; +}; + +export function createMcpTransportSession( + client: Omit = {}, +): McpTransportSession { + const analyticsToken = encodeSessionId({ + sessionId: newSessionId(), + ...client, + }); + const signed = `${TOKEN_VERSION}.${analyticsToken}`; + return { + id: decodeSessionId(analyticsToken)!.sessionId, + token: `${signed}.${signature(signed)}`, + analyticsToken, + }; +} + +/** + * Verifies a transport session minted by this server. The client-controlled + * header is never used for capability state until its HMAC is valid. + */ +export function verifyMcpTransportSession( + token: string | null | undefined, +): McpTransportSession | null { + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return null; + const signed = `${parts[0]}.${parts[1]}`; + const expected = Buffer.from(signature(signed)); + const actual = Buffer.from(parts[2]); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return null; + } + const payload = decodeSessionId(parts[1]); + if (!payload) return null; + return { + id: payload.sessionId, + token, + analyticsToken: parts[1], + }; +} diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index bfc938e..8241042 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -1,9 +1,6 @@ import { - encodeSessionId, getMoreToolsResult, instrument, - MCP_SESSION_HEADER, - newSessionId, PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty, } from "@posthog/mcp"; @@ -209,48 +206,6 @@ export function instrumentMcpAnalytics(server: McpServer) { registerMissingCapabilityTool(server); } -type InitializeRequestBody = { - method?: string; - params?: { - clientInfo?: { name?: string; version?: string }; - protocolVersion?: string; - }; -}; - -/** - * mcp-handler answers over SSE with a stateless transport, so it never issues an - * `Mcp-Session-Id`. Left alone every request becomes its own PostHog session and the - * client name is lost after the handshake. Mint the SDK's session token on the - * initialize request instead: it goes back to the client on the response, the client - * replays it, and any instance decodes the same session id and client info out of it. - * - * Returns the token, or null when there's nothing to mint. Safe on the stateless - * transport, which ignores an incoming session id. - */ -export async function mintMcpSessionId(req: Request): Promise { - if (!posthog || req.headers.get(MCP_SESSION_HEADER)) return null; - - // Streamable HTTP only. The legacy SSE transport issues its own session id and its - // clients don't replay ours, which would put the handshake in one session and the - // calls that follow in another. - if (!new URL(req.url).pathname.endsWith("/mcp")) return null; - - const body = (await req - .clone() - .json() - .catch(() => null)) as InitializeRequestBody | null; - if (body?.method !== "initialize") return null; - - return encodeSessionId({ - sessionId: newSessionId(), - clientName: body.params?.clientInfo?.name, - clientVersion: body.params?.clientInfo?.version, - // Negotiated once, at the handshake. Carried in the token so the events after it - // report it too. - protocolVersion: body.params?.protocolVersion, - }); -} - /** * Drains queued events after the response has been sent, so capture never adds * latency to a tool call. diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts new file mode 100644 index 0000000..8bac97b --- /dev/null +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -0,0 +1,2 @@ +// Generated by scripts/build-managed-auth-app.mjs. Do not edit. +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx new file mode 100644 index 0000000..848a239 --- /dev/null +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -0,0 +1,384 @@ +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createRoot } from "react-dom/client"; +import { KernelManagedAuth } from "@onkernel/managed-auth-react"; +import "@onkernel/managed-auth-react/styles.css"; +import { createManagedAuthFetch } from "./managed-auth-fetch"; +import { useManagedAuthAutofocus } from "./managed-auth-focus"; +import { + initialManagedAuthFlowState, + managedAuthFlowReducer, + waitArgumentsFromBegin, +} from "./managed-auth-flow"; +import { ManagedAuthHostBridge } from "./managed-auth-host"; +import type { + BeginResult, + JsonObject, + LauncherResult, + WaitToolResult, +} from "./managed-auth-types"; +import { ConsentView, MessageView, TerminalView } from "./managed-auth-views"; + +const FAILURE_CONTEXT = + "Managed authentication stopped. Verify its terminal state and report the recovery option; do not continue the protected action."; +const host = new ManagedAuthHostBridge(); + +function sanitizeBeginArguments(input: JsonObject): JsonObject { + const allowed = [ + "mode", + "connection_id", + "domain", + "profile_name", + "save_credentials", + "record_session", + "browser_telemetry", + "proxy_id", + "proxy_name", + ]; + return Object.fromEntries( + allowed + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]]), + ); +} + +function ManagedAuthApp() { + const launcher = useSyncExternalStore( + host.subscribe, + host.getSnapshot, + host.getSnapshot, + ); + const [flow, dispatch] = useReducer( + managedAuthFlowReducer, + initialManagedAuthFlowState, + ); + const [dismissed, setDismissed] = useState(false); + const [embeddedFailure, setEmbeddedFailure] = useState< + "fallback" | "retry" | null + >(null); + const [pollRequest, setPollRequest] = useState(0); + const pollingStarted = useRef(false); + const pollTimer = useRef(null); + const launcherResult = launcher.result as LauncherResult | null; + const connection = flow.begin?.structuredContent?.connection; + const privateAuth = + flow.begin?._meta?.auth_login ?? flow.begin?.structuredContent?.app_private; + const targetDomain = + launcherResult?.structuredContent?.connection?.domain ?? + (launcher.input?.domain as string | undefined) ?? + "this site"; + const appearance = useMemo( + () => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }), + [launcher.theme], + ); + const fetchAdapter = useMemo( + () => createManagedAuthFetch(window.fetch.bind(window)), + [], + ); + + useManagedAuthAutofocus(); + + useEffect(() => { + host.reportSize(); + }); + + useEffect( + () => () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }, + [], + ); + + const publishTerminal = useCallback( + async (outcome: "success" | "failure") => { + const resumeId = flow.begin?.structuredContent?.resume_id; + if (!resumeId || host.destroyed) return; + if ( + !host.claimOneShot(`kernel-managed-auth-context:${resumeId}:${outcome}`) + ) { + return; + } + const terminalConnection = flow.terminalConnection ?? connection; + const domain = terminalConnection?.domain ?? targetDomain; + const profileName = terminalConnection?.profile_name; + const safeTarget = { + domain, + ...(profileName && { profile_name: profileName }), + }; + const context = + outcome === "failure" + ? { + text: FAILURE_CONTEXT, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + }, + } + : { + text: `Kernel managed authentication reported completion for ${JSON.stringify(safeTarget)}. Verify the connection through manage_auth_connections before continuing the pending task.`, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + ...safeTarget, + }, + }; + try { + await host.request("ui/update-model-context", { + content: [{ type: "text", text: context.text }], + structuredContent: context.structuredContent, + }); + } catch { + // The verified terminal state remains visible in the App. + } + }, + [connection, flow.begin, flow.terminalConnection, targetDomain], + ); + + useEffect(() => { + if (flow.phase === "terminal" && flow.outcome) { + void publishTerminal(flow.outcome); + } + }, [flow.outcome, flow.phase, publishTerminal]); + + useEffect(() => { + const observing = + flow.begin?.structuredContent?.state === "observing" || + !privateAuth?.handoff_code || + embeddedFailure === "fallback"; + if (flow.phase !== "active" || (!pollingStarted.current && !observing)) { + return; + } + pollingStarted.current = true; + let cancelled = false; + + const poll = async () => { + if (cancelled || host.destroyed) return; + const args = waitArgumentsFromBegin(flow.begin); + if (!args) { + dispatch({ + type: "WAIT_RECEIVED", + result: { + isError: true, + content: [ + { + type: "text", + text: "The server did not provide a secure wait checkpoint. Close and retry.", + }, + ], + }, + }); + return; + } + try { + const result = await host.callTool( + "manage_auth_connections", + args, + ); + if (cancelled) return; + dispatch({ type: "WAIT_RECEIVED", result }); + if ( + !result.isError && + (!result.structuredContent?.state || + result.structuredContent.state === "pending") + ) { + pollTimer.current = window.setTimeout(poll, 1000); + } + } catch { + if (cancelled) return; + dispatch({ type: "WAIT_TRANSPORT_FAILED" }); + pollTimer.current = window.setTimeout(poll, 3000); + } + }; + + pollTimer.current = window.setTimeout(poll, 500); + return () => { + cancelled = true; + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }; + }, [ + embeddedFailure, + flow.begin, + flow.phase, + pollRequest, + privateAuth?.handoff_code, + ]); + + async function begin() { + if (!launcher.input || flow.phase === "starting") return; + dispatch({ type: "BEGIN_REQUESTED" }); + try { + const result = await host.callTool( + "begin_auth_login", + sanitizeBeginArguments(launcher.input), + ); + dispatch({ type: "BEGIN_RECEIVED", result }); + } catch { + dispatch({ type: "BEGIN_FAILED" }); + } + } + + function startPolling() { + pollingStarted.current = true; + setPollRequest((value) => value + 1); + } + + function closePanel() { + host.collapsed = true; + setDismissed(true); + window.setTimeout(() => host.reportSize(), 0); + } + + if (dismissed) { + return