From 6d1ab5dc814f70c4cac8f11d6e38757e4b3527e2 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 27 Jul 2026 17:38:45 -0400 Subject: [PATCH 01/12] feat: add managed auth MCP app --- .env.example | 6 +- .github/workflows/ci.yml | 8 +- .prettierignore | 1 + AGENTS.md | 14 +- README.md | 18 +- bun.lock | 10 + docs/mcp-apps-prototype.md | 220 ++++++ package.json | 7 +- scripts/build-managed-auth-app.mjs | 84 ++ scripts/mcp-apps-proxy.ts | 432 +++++++++++ scripts/qa-harness-test.mjs | 135 ++++ .../auth/connections/[...path]/route.test.ts | 220 ++++++ .../auth/connections/[...path]/route.ts | 200 +++++ .../mcp/apps/generated/managed-auth-app.ts | 2 + src/lib/mcp/apps/managed-auth-entry.tsx | 723 ++++++++++++++++++ src/lib/mcp/register.ts | 12 +- src/lib/mcp/tools/auth-connections.test.ts | 295 +++++++ src/lib/mcp/tools/auth-connections.ts | 268 ++----- src/lib/mcp/tools/auth-login-app.test.ts | 164 ++++ src/lib/mcp/tools/auth-login-app.ts | 397 ++++++++++ src/lib/mcp/tools/browsers.ts | 5 +- src/lib/mcp/tools/live-view-app.ts | 567 ++++++++++++++ src/lib/mcp/tools/managed-auth-state.ts | 434 +++++++++++ src/lib/mcp/tools/playwright.ts | 2 +- src/proxy.ts | 4 + 25 files changed, 3998 insertions(+), 230 deletions(-) create mode 100644 .prettierignore create mode 100644 docs/mcp-apps-prototype.md create mode 100644 scripts/build-managed-auth-app.mjs create mode 100644 scripts/mcp-apps-proxy.ts create mode 100644 scripts/qa-harness-test.mjs create mode 100644 src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts create mode 100644 src/app/managed-auth-proxy/auth/connections/[...path]/route.ts create mode 100644 src/lib/mcp/apps/generated/managed-auth-app.ts create mode 100644 src/lib/mcp/apps/managed-auth-entry.tsx create mode 100644 src/lib/mcp/tools/auth-connections.test.ts create mode 100644 src/lib/mcp/tools/auth-login-app.test.ts create mode 100644 src/lib/mcp/tools/auth-login-app.ts create mode 100644 src/lib/mcp/tools/live-view-app.ts create mode 100644 src/lib/mcp/tools/managed-auth-state.ts diff --git a/.env.example b/.env.example index acf98af..7c7af38 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 QA: 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 c6b331d..280c2e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,5 +15,11 @@ jobs: - 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/AGENTS.md b/AGENTS.md index 65c6828..b34534b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,10 +141,10 @@ The `KERNEL_API_KEY` env var is passed as the Bearer token and forwarded to the ### Services overview -| Service | Required | Notes | -|---------|----------|-------| -| Next.js dev server (port 3002) | Yes | `make dev` — the MCP server itself | -| Clerk (SaaS) | Yes | OAuth 2.1 auth — config injected via `op run` | -| Redis | Yes | OAuth org-context storage — URL injected via `op run` | -| Kernel Platform API (SaaS) | Yes | Backend for all MCP tools — reached via SDK | -| Mintlify (SaaS) | Optional | Only for `search_docs` tool | +| Service | Required | Notes | +| ------------------------------ | -------- | ----------------------------------------------------- | +| Next.js dev server (port 3002) | Yes | `make dev` — the MCP server itself | +| Clerk (SaaS) | Yes | OAuth 2.1 auth — config injected via `op run` | +| Redis | Yes | OAuth org-context storage — URL injected via `op run` | +| Kernel Platform API (SaaS) | Yes | Backend for all MCP tools — reached via SDK | +| Mintlify (SaaS) | Optional | Only for `search_docs` tool | diff --git a/README.md b/README.md index 6529328..91ba5e8 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ Configure these values wherever the tool expects MCP server settings. ## Tools (16 total) -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. 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 +272,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` - List and get sanitized managed-auth connections. Use domain-filtered `list` as the discovery entrypoint; connection mutations stay in the secure App boundary. - `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 +283,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. Credentials and MFA never enter MCP/model traffic. ## Resources @@ -327,6 +328,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 `domain_filter`. +2. Fetch all pages. If multiple profiles match, ask the user which profile to use. +3. If the selected connection needs auth, explain why and wait for consent before calling `open_auth_login`. +4. The user enters credentials/MFA only in the secure MCP App. Never ask for them in chat. +5. Re-fetch the connection and create the browser with its `profile_name` only after it reports `AUTHENTICATED`. + +If no App appears, ask the user to confirm that before retrying `open_auth_login` with +`text_only: true`. That compatibility path returns a capability-bearing hosted login URL as +user-audience text. Returning the URL does not mean login succeeded; always verify the +connection afterward. + ### Set up browser profiles for authentication ``` diff --git a/bun.lock b/bun.lock index cb24d63..6953dfc 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", + "@onkernel/managed-auth-react": "0.4.1", "@onkernel/sdk": "^0.78.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -31,6 +32,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", @@ -145,6 +147,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@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.78.0", "", {}, "sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw=="], "@redis/bloom": ["@redis/bloom@5.6.0", "", { "peerDependencies": { "@redis/client": "^5.6.0" } }, "sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg=="], @@ -193,6 +197,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/jszip": ["@types/jszip@3.4.1", "", { "dependencies": { "jszip": "*" } }, "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A=="], @@ -221,6 +227,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -235,6 +243,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/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md new file mode 100644 index 0000000..2ff2b65 --- /dev/null +++ b/docs/mcp-apps-prototype.md @@ -0,0 +1,220 @@ +# MCP Apps: browser live view and managed authentication + +## Managed Auth App + +The managed-auth flow is discovery-first and fail-closed: + +```text +manage_auth_connections(action="list", domain_filter=...) + -> ask the user to choose/consent + -> open_auth_login(...) + -> user clicks Continue in the App + -> begin_auth_login (app-only) + -> bundled KernelManagedAuth uses the scoped relay + -> get_auth_login_status (app-only) + -> Continue agent + -> agent re-fetches and proceeds only if AUTHENTICATED +``` + +`open_auth_login` does not create a connection or flow until the user clicks Continue. The +single-file `ui://kernel/managed-auth-login-v4.html` resource bundles +`@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, +managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff +code and hosted fallback URL exist only in the app-only `begin_auth_login` result `_meta`. +The component sends credential input directly to `/managed-auth-proxy`, which accepts only +exchange, retrieve, submit, and events paths and forwards only managed-auth scoped JWTs. + +For clients without Apps, first confirm that no panel appeared, then call `open_auth_login` +with `text_only: true`. This explicit compatibility exception places the full hosted URL +(including its embedded handoff capability) in user-audience text. It never emits a separate +`handoff_code`, and the agent must re-fetch the connection instead of treating URL creation +as successful authentication. + +Local QA: + +```bash +MANAGED_AUTH_APP_ORIGIN=http://localhost:3002 bun run dev +KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts +# Open http://localhost:3003/qa/auth, or: +bun scripts/qa-harness-test.mjs --auth +``` + +The QA host intentionally logs only app tool names, never app-private tool results or hosted +URLs. Delete the test connection with the harness cleanup button. + +## Embedded browser live view + +Prototype of [MCP Apps (SEP-1865)](https://apps.extensions.modelcontextprotocol.io/) in the +Kernel MCP server: when an agent spins up a Kernel browser, it can render an **embedded, +read-only view** of that browser inline in the chat (auto-refreshing snapshots), so the user +watches the automation happen in real time. The full interactive live view is one click away +via `ui/open-link`. + +## What was added + +| File | Purpose | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/lib/mcp/tools/live-view-app.ts` | The MCP App: a `ui://kernel/live-view.html` resource (`text/html;profile=mcp-app`) + a `show_browser_live_view` tool linked to it via `_meta.ui.resourceUri`. The view speaks raw postMessage JSON-RPC per the spec and renders read-only snapshots by polling the app-only `capture_live_view_frame` tool through the host. | +| `src/lib/mcp/register.ts` | Registers the new `live_view_app` toolset. | +| `src/lib/mcp/tools/browsers.ts` | `manage_browsers` create/update responses now hint the agent to call `show_browser_live_view` right after creating a session. | +| `scripts/mcp-apps-proxy.ts` | Dev-only reverse proxy (port 3003) that injects `Authorization: Bearer $KERNEL_API_KEY` so Claude's custom connectors (which can't send static bearer tokens) can use the server as a "no auth" connector. It also 404s the server's OAuth surface (`/.well-known/*`, `/register`, `/authorize`, `/token`) — otherwise Claude discovers it and attempts dynamic client registration against the dummy Clerk config, failing with "Couldn't register with … sign-in service". Also serves a `/qa` harness page. | +| `scripts/qa-harness-test.mjs` | Playwright smoke test that drives the `/qa` harness end to end. | + +How it works (per the spec): + +1. `tools/list` exposes `show_browser_live_view` with `_meta.ui.resourceUri: "ui://kernel/live-view.html"`. +2. When the agent calls the tool, an MCP Apps-capable host fetches the resource via + `resources/read` and renders the HTML in a sandboxed iframe. +3. The view does the `ui/initialize` → `ui/notifications/initialized` handshake and receives + the tool result (`session_id` + live view URLs) via `ui/notifications/tool-result`. +4. The view then polls the app-only `capture_live_view_frame` tool through the host + (`tools/call` over postMessage) every ~2.5s and swaps the returned PNG frames into an + `` as `data:` URIs. +5. **No nested iframe, no external origins**: the spec's restrictive default CSP is enough. + We deliberately don't iframe the real live view — Claude blocks third-party `frameDomains` + pending security review, and snapshots work identically in every host. +6. Hosts without MCP Apps support just see the URLs as text — graceful fallback. + +## Running the stack locally + +Three processes: + +```bash +# 1. The MCP server (port 3002). .env.local only needs format-valid dummy Clerk +# values — the API-key auth path never touches Clerk/Redis. +bun run dev + +# 2. The auth-injecting proxy (port 3003 -> 3002) +KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts + +# 3. The tunnel (points at the PROXY, not the server) +ngrok http --url=raf-kernel-mcp-server.ngrok.app 3003 +``` + +MCP endpoint: `https://raf-kernel-mcp-server.ngrok.app/mcp` + +> ⚠️ **Security**: anyone with the tunnel URL gets your Kernel account (the proxy injects your +> API key on every request). Tear the tunnel down when you're done. Never point the proxy at a +> production deployment. + +## Adding to Claude (Desktop or claude.ai) + +Requires a plan with custom connectors (Pro/Max/Team) and a recent Claude version with MCP +Apps support. + +1. **Settings → Connectors → Add custom connector** +2. Name: `Kernel (dev)`, URL: `https://raf-kernel-mcp-server.ngrok.app/mcp` → **Add**. + No OAuth/Connect prompt should appear (the proxy handles auth and hides the OAuth + discovery endpoints); the connector should be immediately usable. +3. In a chat, open **Search and tools** and make sure the Kernel tools are enabled. + +If you previously added the connector and got _"Couldn't register with … sign-in service"_, +**remove the connector and re-add it** — Claude caches the discovered OAuth metadata from +the first attempt. + +## QA script (Claude) + +1. Prompt: _"Create a Kernel browser and show me its live view, then go to + news.ycombinator.com and tell me the top story."_ +2. Expect the agent to call `manage_browsers` (create) → `show_browser_live_view` → the app + renders inline: header "Kernel browser" with a green **READ-ONLY** badge, then browser + snapshots appear within a few seconds and refresh every ~2.5s. +3. While the agent drives the browser (`execute_playwright_code` / `computer_action`), watch + navigation happen live in the embedded view. +4. Read-only check: click/type inside the embedded view — the page should not react. +5. Click **Open interactive view ↗** — the full interactive live view should open in your + browser (host shows a link confirmation first). +6. Say _"delete the browser"_ — after deletion the embed goes dark/disconnects (expected; + the stream ended). + +Failure modes to watch for: + +- App doesn't render, tool result shows as plain text → host lacks MCP Apps support (old + Claude version, or apps not enabled for connectors). +- App renders but frames never appear → the host is not proxying app-initiated `tools/call` + (check the debug line for "snapshot failed" and the proxy log for + `rpc tools/call name=capture_live_view_frame`). +- "Waiting for browser live view…" forever → tool result never reached the view; check the + host delivered `ui/notifications/tool-result` (host devtools) and that the session wasn't + headless (headless sessions have no live view). + +## QA without Claude (any browser) + +The proxy serves a minimal MCP Apps host at: + +``` +https://raf-kernel-mcp-server.ngrok.app/qa (or http://localhost:3003/qa) +``` + +Click **1. Create browser & render app** (creates a real browser + renders the real +`ui://` resource and drives the postMessage lifecycle), **2. Navigate** to watch the stream +update live, **3. Delete browser** to clean up. If ngrok shows its interstitial page first, +click "Visit Site". + +Automated version: `bun scripts/qa-harness-test.mjs` (screenshots in `/tmp/qa-*.png`). + +Protocol-level checks with curl (API key as bearer): + +```bash +# tool metadata carries the ui resource link +curl -s -X POST http://localhost:3002/mcp \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer $KERNEL_API_KEY" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +# -> show_browser_live_view has _meta.ui.resourceUri = ui://kernel/live-view.html + +# the UI resource itself +curl -s -X POST http://localhost:3002/mcp \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer $KERNEL_API_KEY" \ + -d '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"ui://kernel/live-view.html"}}' +# -> mimeType text/html;profile=mcp-app, _meta.ui.csp.frameDomains for *.onkernel.com +``` + +## Claude-specific findings (from QA) + +- **Claude blocks nested third-party iframes entirely.** Per Claude's MCP Apps design + guidelines: _"`frameDomains` (embedding third-party iframes) is currently restricted in + Claude pending security review."_ An earlier iteration of this prototype iframed the real + live view and hit `frame-src` CSP violations in Claude; that's why the app is now + **snapshot-only** (polling `capture_live_view_frame`, visibility `["app"]`). If Kernel ever + wants the true WebRTC stream inline, revisit once Claude ships frameDomains support. +- **Custom remote connectors in Claude may connect via a runtime that doesn't support MCP + Apps** (`client=claude-code`, no `io.modelcontextprotocol/ui` extension — renders a blank + box). The reliable path is a **local stdio server** in `claude_desktop_config.json` bridged + with `mcp-remote`: + + ```json + { + "mcpServers": { + "kernel-dev": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://raf-kernel-mcp-server.ngrok.app/mcp" + ] + } + } + } + ``` + +- Claude's getting-started docs list example packages as `@modelcontextprotocol/-server`; + the real npm names are `@modelcontextprotocol/server-` (e.g. `server-shadertoy`). +- Debugging: Claude Desktop → Help → Troubleshooting → Enable Developer Mode, then + `Cmd+Option+I`; the app is the inner of two nested iframes. The view also prints lifecycle + state to a debug line at the bottom of the widget. + +## Known limitations / next steps + +- **Capability negotiation**: `mcp-handler` creates a fresh MCP server for every Streamable + HTTP POST, so initialize capabilities are unavailable when a later `tools/list` request is + handled. App-only helpers therefore use the MCP Apps `visibility: ["app"]` contract rather + than server-side capability filtering; they also reject calls without a short-lived, + bearer-bound capability delivered only in the launcher's App-private `_meta`. +- The `manage_browsers` tool itself could carry the UI metadata so the app renders on create + without a second tool call; kept separate to limit blast radius. +- Production auth: this prototype's proxy is dev-only. Production would use the existing + OAuth flow (mcp.onkernel.com) — no proxy needed since Claude does OAuth there. +- The live view URL expires with the session; the view could poll session state via an + app-visible tool (`visibility: ["app"]`) and show a "session ended" state. diff --git a/package.json b/package.json index 46f6178..379c90a 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ }, "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 build:managed-auth-app && next build", "start": "next start -p 3002", "lint": "next lint", + "test": "bun test", "format": "prettier --write \"**/*.{ts,js,json,md}\"", "format:check": "prettier --check \"**/*.{ts,js,json,md}\"" }, @@ -34,6 +37,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", + "@onkernel/managed-auth-react": "0.4.1", "@onkernel/sdk": "^0.78.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -55,6 +59,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", diff --git a/scripts/build-managed-auth-app.mjs b/scripts/build-managed-auth-app.mjs new file mode 100644 index 0000000..45e163c --- /dev/null +++ b/scripts/build-managed-auth-app.mjs @@ -0,0 +1,84 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +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 + + +
Preparing secure login…
+ + +`; + 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/scripts/mcp-apps-proxy.ts b/scripts/mcp-apps-proxy.ts new file mode 100644 index 0000000..df170f2 --- /dev/null +++ b/scripts/mcp-apps-proxy.ts @@ -0,0 +1,432 @@ +/** + * Dev-only reverse proxy that injects a Kernel API key as the Authorization + * header in front of the local MCP server. + * + * Why: Claude Desktop / claude.ai custom connectors can only do OAuth or + * no-auth — they cannot send a static bearer token. The kernel-mcp-server + * accepts non-JWT bearer tokens as Kernel API keys, so this proxy makes the + * server usable as a "no auth" connector while still authenticating every + * request upstream with your API key. + * + * KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts + * + * Then point ngrok at this proxy (default port 3003), NOT at the Next server: + * + * ngrok http --url=raf-kernel-mcp-server.ngrok.app 3003 + * + * DO NOT run this against a production deployment or leave the tunnel up + * unattended: anyone with the ngrok URL can use your Kernel account. + */ + +// Bun provides this global at runtime; declared here so the repo's tsconfig +// (which has no Bun types) still typechecks. +declare const Bun: { serve(options: unknown): unknown }; + +const UPSTREAM = process.env.MCP_UPSTREAM ?? "http://localhost:3002"; +const UPSTREAM_URL = new URL(UPSTREAM); +if (!["http:", "https:"].includes(UPSTREAM_URL.protocol)) { + throw new Error("MCP_UPSTREAM must use http or https"); +} +const PORT = Number(process.env.PORT ?? 3003); +const API_KEY = process.env.KERNEL_API_KEY; + +if (!API_KEY) { + console.error("KERNEL_API_KEY is required"); + process.exit(1); +} + +const HOP_BY_HOP = new Set([ + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", +]); + +function filterHeaders(source: Headers): Headers { + const out = new Headers(); + source.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) out.set(key, value); + }); + return out; +} + +// Minimal MCP Apps "host" page for QA-ing the live view app in any browser +// (no MCP Apps-capable client needed). It drives the real MCP server through +// this proxy: initialize -> create browser -> show_browser_live_view -> +// resources/read -> render the view in an iframe and speak host-side +// postMessage JSON-RPC to it. +const QA_HARNESS_HTML = /* html */ ` + + + +Kernel MCP Apps QA Harness + + + +

Kernel MCP Apps QA harness \u2014 embedded live view

+

Simulates an MCP Apps host: creates a real Kernel browser, calls show_browser_live_view, renders the ui://kernel/live-view-v2.html resource below, delivers the tool result over postMessage, and proxies the app's capture_live_view_frame snapshot polling.

+ + + +
+ + + + +`; + +const AUTH_QA_HARNESS_HTML = /* html */ ` + + + +Kernel Managed Auth MCP App QA + + + +

Managed Auth MCP App QA

+

The flow is not created until Continue is clicked inside the embedded App.

+ + + + +
+ + + +`; + +Bun.serve({ + port: PORT, + idleTimeout: 240, + async fetch(req: Request) { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/qa") { + return new Response(QA_HARNESS_HTML, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + if (req.method === "GET" && url.pathname === "/qa/auth") { + return new Response(AUTH_QA_HARNESS_HTML, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + + // Hide the server's OAuth surface. This proxy injects an API key on every + // request, so from the outside this must look like a no-auth MCP server. + // If OAuth discovery leaks through, clients like Claude attempt dynamic + // client registration against the (dummy-configured) Clerk instance and + // the connector fails to connect. + if ( + url.pathname.startsWith("/.well-known/") || + url.pathname === "/register" || + url.pathname === "/authorize" || + url.pathname === "/token" + ) { + console.log( + `[proxy] ${req.method} ${url.pathname} -> 404 (oauth surface hidden)`, + ); + return new Response("Not found", { status: 404 }); + } + + // Assign onto a fixed parsed origin. Constructing from a string beginning + // with `//` would reinterpret an attacker-controlled path as a new host and + // exfiltrate the API key attached below. + const target = new URL(UPSTREAM_URL); + target.pathname = url.pathname; + target.search = url.search; + + const headers = filterHeaders(req.headers); + // Only the MCP endpoint needs the development API-key injection. Managed + // Auth relay requests carry their own short-lived, session-scoped JWT; + // overwriting it would break retrieve/submit after handoff exchange. + if (url.pathname === "/mcp") { + headers.set("authorization", `Bearer ${API_KEY}`); + } + + // Decode JSON-RPC traffic for debugging (bodies are small JSON payloads). + let body: BodyInit | null = req.body; + if (req.method === "POST" && url.pathname === "/mcp") { + const bodyText = await req.text(); + body = bodyText; + try { + const parsed = JSON.parse(bodyText); + const messages = Array.isArray(parsed) ? parsed : [parsed]; + for (const m of messages) { + if (!m || typeof m.method !== "string") continue; + let detail = m.method; + if (m.method === "tools/call") detail += ` name=${m.params?.name}`; + if (m.method === "resources/read") detail += ` uri=${m.params?.uri}`; + if (m.method === "initialize") { + const client = m.params?.clientInfo; + detail += ` client=${client?.name}@${client?.version} extensions=${JSON.stringify( + m.params?.capabilities?.extensions ?? null, + )}`; + } + console.log(`[proxy] rpc ${detail}`); + } + } catch { + /* non-JSON body; forward as-is */ + } + } + + let upstream: Response; + try { + upstream = await fetch(target, { + method: req.method, + headers, + body, + redirect: "manual", + // @ts-expect-error half-duplex streaming request bodies + duplex: "half", + }); + } catch (error) { + console.error( + `[proxy] ${req.method} ${url.pathname} -> upstream error`, + error, + ); + return new Response("Upstream unavailable", { status: 502 }); + } + + console.log(`[proxy] ${req.method} ${url.pathname} -> ${upstream.status}`); + return new Response(upstream.body, { + status: upstream.status, + headers: filterHeaders(upstream.headers), + }); + }, +}); + +console.log( + `mcp-apps auth proxy listening on http://localhost:${PORT} -> ${UPSTREAM} (injecting Kernel API key)`, +); diff --git a/scripts/qa-harness-test.mjs b/scripts/qa-harness-test.mjs new file mode 100644 index 0000000..2a409c5 --- /dev/null +++ b/scripts/qa-harness-test.mjs @@ -0,0 +1,135 @@ +import { chromium } from "playwright"; + +const authMode = process.argv.includes("--auth"); +const qaBaseUrl = process.env.QA_BASE_URL ?? "http://localhost:3003"; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1000, height: 1500 } }); +page.on("pageerror", (error) => console.log("[pageerror]", error.message)); +const logText = () => page.locator("#log").textContent(); + +if (authMode) { + let failure; + try { + await page.goto(`${qaBaseUrl}/qa/auth`); + await page.fill("#profile", `mcp-auth-qa-${Date.now()}`); + await page.click("#start"); + await page.waitForFunction( + () => { + const text = document.getElementById("log").textContent; + return ( + text.includes("Secure App resource rendered") || + text.includes("ERROR") + ); + }, + null, + { timeout: 60000 }, + ); + if ((await logText()).includes("ERROR")) { + throw new Error(`Auth QA launcher failed:\n${await logText()}`); + } + + const exchangeResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname.endsWith("/exchange") && + response.request().method() === "POST", + { timeout: 120000 }, + ); + const retrieveResponse = page.waitForResponse( + (response) => + /^\/managed-auth-proxy\/auth\/connections\/[^/]+$/.test( + new URL(response.url()).pathname, + ) && response.request().method() === "GET", + { timeout: 120000 }, + ); + + const app = page.frameLocator("#view"); + await app.getByRole("button", { name: /continue/i }).click(); + await page.waitForFunction( + () => { + const text = document.getElementById("log").textContent; + return ( + text.includes("Managed-auth connection ready") || + text.includes("ERROR") + ); + }, + null, + { timeout: 120000 }, + ); + if ((await logText()).includes("ERROR")) { + throw new Error(`Auth QA begin failed:\n${await logText()}`); + } + + const [exchange, retrieve] = await Promise.all([ + exchangeResponse, + retrieveResponse, + ]); + if (!exchange.ok() || !retrieve.ok()) { + throw new Error( + `Managed-auth relay failed: exchange=${exchange.status()} retrieve=${retrieve.status()}`, + ); + } + await page.screenshot({ + path: "/tmp/qa-managed-auth-app.png", + fullPage: true, + }); + console.log( + `--- managed-auth QA ---\n${await logText()}relay exchange=${exchange.status()} retrieve=${retrieve.status()}\n`, + ); + } catch (error) { + failure = error; + } finally { + if ( + await page + .locator("#cleanup") + .isEnabled() + .catch(() => false) + ) { + await page.click("#cleanup"); + await page.waitForFunction( + () => document.getElementById("log").textContent.includes("deleted"), + null, + { timeout: 60000 }, + ); + } + } + if (failure) throw failure; +} else { + await page.goto(`${qaBaseUrl}/qa`); + await page.click("#start"); + await page.waitForFunction( + () => { + const text = document.getElementById("log").textContent; + return text.includes("view rendered") || text.includes("ERROR"); + }, + null, + { timeout: 180000 }, + ); + console.log("--- after start ---\n" + (await logText())); + + await page.waitForTimeout(10000); + await page.screenshot({ path: "/tmp/qa-1-rendered.png", fullPage: true }); + + await page.click("#navigate"); + await page.waitForFunction( + () => { + const text = document.getElementById("log").textContent; + return text.includes("navigated") || text.split("ERROR").length > 1; + }, + null, + { timeout: 120000 }, + ); + await page.waitForTimeout(5000); + await page.screenshot({ path: "/tmp/qa-2-navigated.png", fullPage: true }); + console.log("--- after navigate ---\n" + (await logText())); + + await page.click("#cleanup"); + await page.waitForFunction( + () => + document.getElementById("log").textContent.includes("browser deleted"), + null, + { timeout: 60000 }, + ); + console.log("--- after cleanup ---\n" + (await logText())); +} + +await browser.close(); 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..a819264 --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts @@ -0,0 +1,220 @@ +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); + + 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("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..2fde2da --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts @@ -0,0 +1,200 @@ +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 validOperation(path: string[], method: string): boolean { + if (path.length === 1 && method === "GET") return path[0].length > 0; + if (path.length !== 2 || !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 && !!path[0]) || + (path.length === 2 && + !!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" + ) { + 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/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts new file mode 100644 index 0000000..3bbe696 --- /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
Preparing secure login…
\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..6785582 --- /dev/null +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -0,0 +1,723 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + AppearanceProvider, + KernelManagedAuth, + LocalizationProvider, + Shell, + StepError, + StepPrime, + StepSuccess, +} from "@onkernel/managed-auth-react"; +import "@onkernel/managed-auth-react/styles.css"; + +const FAILURE_CONTEXT = + "Managed authentication stopped. Verify its terminal state and report the recovery option; do not continue the protected action."; + +type JsonObject = Record; +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; +type SafeConnection = { + id: string; + domain: string; + profile_name: string; + status: "AUTHENTICATED" | "NEEDS_AUTH"; + flow_status: + | "IN_PROGRESS" + | "SUCCESS" + | "FAILED" + | "EXPIRED" + | "CANCELED" + | null; + flow_type: "LOGIN" | "REAUTH" | null; + flow_expires_at: string | null; +}; +type BeginResult = { + structuredContent?: { + state?: string; + connection?: SafeConnection; + resume_id?: string; + app_private?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + _meta?: { + auth_login?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + isError?: boolean; +}; + +let nextRequestId = 1; +const pendingRequests = new Map(); +let destroyed = false; +let reactRoot: Root | null = null; +let completeToolInput: JsonObject | null = null; +let launcherToolResult: JsonObject | null = null; +let hostTheme: "light" | "dark" | "auto" = "auto"; +const listeners = new Set<() => void>(); +let stateVersion = 0; +const oneShotKeys = new Set(); + +function claimOneShot(key: string): boolean { + if (oneShotKeys.has(key)) return false; + try { + if (sessionStorage.getItem(key)) return false; + sessionStorage.setItem(key, "1"); + } catch { + // Opaque sandbox origins may deny storage; the in-memory guard still works. + } + oneShotKeys.add(key); + return true; +} + +function releaseOneShot(key: string) { + oneShotKeys.delete(key); + try { + sessionStorage.removeItem(key); + } catch { + // See claimOneShot. + } +} + +function notifyStateChanged() { + stateVersion += 1; + for (const listener of listeners) listener(); +} + +function sendRequest(method: string, params: JsonObject): Promise { + const id = nextRequestId++; + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); + }); +} + +function sendNotification(method: string, params: JsonObject) { + window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); +} + +function callTool(name: string, args: JsonObject): Promise { + return sendRequest("tools/call", { + name, + arguments: args, + }) as Promise; +} + +function applyHostContext(context: JsonObject | undefined) { + const theme = context?.theme; + if (theme === "light" || theme === "dark") hostTheme = theme; + const styles = context?.styles as + | { variables?: Record } + | undefined; + if (styles?.variables) { + for (const [key, value] of Object.entries(styles.variables)) { + document.documentElement.style.setProperty(key, value); + } + } + notifyStateChanged(); +} + +function reportSize() { + sendNotification("ui/notifications/size-changed", { + height: Math.max(document.documentElement.scrollHeight, 360), + }); +} + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window.parent) return; + const message = event.data as + | { + jsonrpc?: string; + id?: number; + method?: string; + params?: JsonObject; + result?: unknown; + error?: { message?: string }; + } + | undefined; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id !== undefined && !message.method) { + const pending = pendingRequests.get(message.id); + if (!pending) return; + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? "Host request failed")); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "ui/resource-teardown" && message.id !== undefined) { + destroyed = true; + for (const pending of pendingRequests.values()) { + pending.reject(new Error("Managed-auth App was closed")); + } + pendingRequests.clear(); + listeners.clear(); + reactRoot?.unmount(); + reactRoot = null; + window.parent.postMessage( + { jsonrpc: "2.0", id: message.id, result: {} }, + "*", + ); + return; + } + + if (message.id !== undefined && message.method) { + window.parent.postMessage( + { jsonrpc: "2.0", id: message.id, result: {} }, + "*", + ); + return; + } + + switch (message.method) { + case "ui/notifications/tool-input": + completeToolInput = message.params?.arguments as JsonObject; + notifyStateChanged(); + break; + case "ui/notifications/tool-result": + launcherToolResult = message.params ?? null; + notifyStateChanged(); + break; + case "ui/notifications/host-context-changed": + applyHostContext(message.params); + break; + } +}); + +function useLauncherData() { + useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => stateVersion, + ); + return { + input: completeToolInput, + result: launcherToolResult, + theme: hostTheme, + }; +} + +function isRetryableHttpStatus(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +function sanitizeBeginArguments(input: JsonObject): JsonObject { + const allowed = [ + "mode", + "connection_id", + "domain", + "profile_name", + "save_credentials", + "proxy_id", + "proxy_name", + ]; + return Object.fromEntries( + allowed + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]]), + ); +} + +function ManagedAuthApp() { + const launcher = useLauncherData(); + const [beginResult, setBeginResult] = useState(null); + const [starting, setStarting] = useState(false); + const [terminal, setTerminal] = useState<"success" | "failure" | null>(null); + const [statusText, setStatusText] = useState(""); + const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); + const [embeddedRetryRequired, setEmbeddedRetryRequired] = useState(false); + const pollTimer = useRef(null); + const pollingStarted = useRef(false); + const mountedFlow = useRef(false); + const hostedFallbackAvailable = useRef(true); + const exchangedJwt = useRef(null); + const retrieveInitializationFailed = useRef(false); + + const launcherContent = launcher.result?.structuredContent as + | { + connection?: { domain?: string; profile_name?: string }; + } + | undefined; + const targetDomain = + (launcherContent?.connection?.domain as string | undefined) ?? + (launcher.input?.domain as string | undefined) ?? + "this site"; + const connection = beginResult?.structuredContent?.connection; + const resumeId = beginResult?.structuredContent?.resume_id; + const privateAuth = + beginResult?._meta?.auth_login ?? + beginResult?.structuredContent?.app_private; + + const appearance = useMemo( + () => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }), + [launcher.theme], + ); + + const managedAuthFetch = useCallback( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const pathname = new URL(url, window.location.href).pathname; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const isExchange = + method === "POST" && + /\/auth\/connections\/[^/]+\/exchange$/.test(pathname); + const isRetrieve = + method === "GET" && /\/auth\/connections\/[^/]+$/.test(pathname); + + if (isExchange && exchangedJwt.current) { + return new Response(JSON.stringify({ jwt: exchangedJwt.current }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const attempts = isRetrieve ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(input, init); + if (isExchange && !isRetryableHttpStatus(response.status)) { + // A definitive exchange response means the hosted URL is no longer + // a safe fallback. Retryable failures leave it for the hosted UI. + hostedFallbackAvailable.current = false; + if (response.ok) { + const data = (await response.clone().json()) as { jwt?: unknown }; + if (typeof data.jwt === "string") exchangedJwt.current = data.jwt; + } + } + if (isRetrieve) { + if (response.ok) retrieveInitializationFailed.current = false; + else if ( + isRetryableHttpStatus(response.status) && + attempt + 1 < attempts + ) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } else { + retrieveInitializationFailed.current = isRetryableHttpStatus( + response.status, + ); + } + } + return response; + } catch (error) { + if (isRetrieve && attempt + 1 < attempts) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } + if (isRetrieve) retrieveInitializationFailed.current = true; + throw error; + } + } + throw new Error("Managed-auth request retry exhausted"); + }, + [], + ); + + useEffect(() => { + reportSize(); + }); + + useEffect(() => { + return () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }; + }, []); + + function terminalContext(outcome: "success" | "failure") { + if (outcome === "failure") { + return { + text: FAILURE_CONTEXT, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + }, + }; + } + + const domain = connection?.domain ?? targetDomain; + const profileName = connection?.profile_name; + const safeTarget = { + domain, + ...(profileName && { profile_name: profileName }), + }; + return { + 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, + }, + }; + } + + async function publishTerminal(outcome: "success" | "failure") { + if (!resumeId || destroyed) return; + const contextKey = `kernel-managed-auth-context:${resumeId}:${outcome}`; + if (!claimOneShot(contextKey)) return; + const context = terminalContext(outcome); + try { + await sendRequest("ui/update-model-context", { + content: [{ type: "text", text: context.text }], + structuredContent: context.structuredContent, + }); + } catch { + // The visible Continue agent action remains available as a fallback. + } + } + + function finish(outcome: "success" | "failure") { + if (terminal || destroyed) return; + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + setTerminal(outcome); + setStatusText(""); + void publishTerminal(outcome); + } + + async function checkStatus() { + if (!connection?.id || destroyed || terminal) return; + const knownExpiry = connection.flow_expires_at + ? Date.parse(connection.flow_expires_at) + : Number.NaN; + try { + const result = await callTool("get_auth_login_status", { + connection_id: connection.id, + }); + const current = result.structuredContent?.connection; + if (!current) throw new Error("No connection status returned"); + if ( + current.flow_status === "FAILED" || + current.flow_status === "EXPIRED" || + current.flow_status === "CANCELED" + ) { + finish("failure"); + return; + } + const expiresAt = current.flow_expires_at + ? Date.parse(current.flow_expires_at) + : Number.NaN; + if ( + current.flow_status !== "SUCCESS" && + Number.isFinite(expiresAt) && + expiresAt <= Date.now() + ) { + finish("failure"); + return; + } + if ( + current.flow_status === "SUCCESS" && + current.status === "AUTHENTICATED" + ) { + finish("success"); + return; + } + setStatusText("Secure login is still in progress…"); + pollTimer.current = window.setTimeout(checkStatus, 2000); + } catch { + if (Number.isFinite(knownExpiry) && knownExpiry <= Date.now()) { + finish("failure"); + return; + } + setStatusText("Checking secure login status…"); + pollTimer.current = window.setTimeout(checkStatus, 3000); + } + } + + async function begin() { + if (!launcher.input || starting) { + setStatusText("Secure login input is unavailable. Close and retry."); + return; + } + setStarting(true); + setStatusText("Preparing secure login…"); + try { + const result = await callTool("begin_auth_login", { + ...sanitizeBeginArguments(launcher.input), + }); + if (result.isError || !result.structuredContent?.connection) { + throw new Error("Secure login could not be prepared"); + } + setBeginResult(result); + setStatusText(""); + } catch { + setStatusText( + "Secure login could not start. Close this panel and retry.", + ); + } finally { + setStarting(false); + } + } + + useEffect(() => { + if (!beginResult || !connection || terminal || pollingStarted.current) { + return; + } + if (beginResult.structuredContent?.state === "already_authenticated") { + finish("success"); + return; + } + if ( + beginResult.structuredContent?.state === "observing" || + !privateAuth?.handoff_code || + embeddedInitFailed + ) { + pollingStarted.current = true; + pollTimer.current = window.setTimeout(checkStatus, 500); + } + }); + + async function continueAgent() { + if (!resumeId || !terminal) return; + const messageKey = `kernel-managed-auth-message:${resumeId}:${terminal}`; + if (!claimOneShot(messageKey)) return; + const context = terminalContext(terminal); + try { + await sendRequest("ui/message", { + role: "user", + content: [{ type: "text", text: context.text }], + }); + setStatusText("Agent notified."); + } catch { + releaseOneShot(messageKey); + setStatusText("Tell the agent to verify the connection and continue."); + } + } + + if (!launcher.input || !launcher.result) { + return
Preparing secure login…
; + } + + if (!beginResult) { + return ( + + + + + {statusText &&

{statusText}

} +
+
+
+ ); + } + + if (terminal) { + return ( + + + + {terminal === "success" ? ( + + ) : ( + + )} +
+ + {statusText &&

{statusText}

} +
+
+
+
+ ); + } + + if (embeddedRetryRequired) { + return ( + + + +
+

Secure login was interrupted

+

+ The secure session is preserved. Retry connecting to continue. +

+ +
+
+
+
+ ); + } + + if (embeddedInitFailed) { + return ( + + + +
+

Embedded login could not connect

+

Continue securely in the hosted login, then return here.

+ {privateAuth?.hosted_url && ( + + )} +
+
+
+
+ ); + } + + if ( + privateAuth?.handoff_code && + privateAuth.relay_base_url && + connection && + !mountedFlow.current + ) { + mountedFlow.current = true; + } + + if (mountedFlow.current && privateAuth?.handoff_code && connection) { + return ( + void checkStatus()} + onError={() => { + if (hostedFallbackAvailable.current && privateAuth.hosted_url) { + setEmbeddedInitFailed(true); + return; + } + if (retrieveInitializationFailed.current && exchangedJwt.current) { + setEmbeddedRetryRequired(true); + return; + } + void checkStatus(); + }} + /> + ); + } + + return ( + + + +
+

Authentication is in progress

+

+ {statusText || "This panel is securely monitoring the login."} +

+ {privateAuth?.hosted_url && ( + + )} +
+
+
+
+ ); +} + +document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a"); + if (!anchor?.href || !anchor.href.startsWith("https://kernel.sh")) return; + event.preventDefault(); + void sendRequest("ui/open-link", { url: anchor.href }); +}); + +async function initialize() { + // Mount before the host handshake. Some hosts create the App container but + // delay or omit the ui/initialize response; waiting here would leave a + // completely blank panel with no actionable diagnostic. + if (destroyed) return; + reactRoot = createRoot(document.getElementById("root")!); + reactRoot.render(); + reportSize(); + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(reportSize).observe(document.body); + } + + const handshake = sendRequest("ui/initialize", { + protocolVersion: "2026-01-26", + capabilities: {}, + appCapabilities: { availableDisplayModes: ["inline", "fullscreen"] }, + clientInfo: { name: "kernel-managed-auth", version: "1.0.1" }, + }) as Promise<{ hostContext?: JsonObject }>; + const timeout = new Promise((resolve) => { + window.setTimeout(() => resolve(null), 4000); + }); + + try { + const result = await Promise.race([handshake, timeout]); + if (result?.hostContext) applyHostContext(result.hostContext); + } catch { + // Compatible hosts may still deliver tool notifications. + } + + if (!destroyed) { + sendNotification("ui/notifications/initialized", {}); + reportSize(); + } +} + +void initialize(); diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 0b810a1..6600551 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -3,6 +3,7 @@ import { registerKernelPrompts } from "@/lib/mcp/prompts"; import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys"; import { registerAppCapabilities } from "@/lib/mcp/tools/apps"; import { registerAuthConnectionTools } from "@/lib/mcp/tools/auth-connections"; +import { registerAuthLoginApp } from "@/lib/mcp/tools/auth-login-app"; import { registerBrowserPoolCapabilities } from "@/lib/mcp/tools/browser-pools"; import { registerBrowserCurlTool } from "@/lib/mcp/tools/browser-curl"; import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; @@ -11,6 +12,7 @@ import { registerCredentialProviderTools } from "@/lib/mcp/tools/credential-prov import { registerCredentialTools } from "@/lib/mcp/tools/credentials"; import { registerDocsTools } from "@/lib/mcp/tools/docs"; import { registerExtensionTools } from "@/lib/mcp/tools/extensions"; +import { registerLiveViewApp } from "@/lib/mcp/tools/live-view-app"; import { registerPlaywrightTool } from "@/lib/mcp/tools/playwright"; import { registerProfileCapabilities } from "@/lib/mcp/tools/profiles"; import { registerProjectCapabilities } from "@/lib/mcp/tools/projects"; @@ -20,10 +22,16 @@ import { registerShellTool } from "@/lib/mcp/tools/shell"; type RegisterMcpToolset = (server: McpServer) => void; +function registerManagedAuthCapabilities(server: McpServer) { + registerAuthConnectionTools(server); + registerAuthLoginApp(server); +} + const mcpToolRegistrations = [ ["profiles", registerProfileCapabilities], ["docs", registerDocsTools], ["browsers", registerBrowserCapabilities], + ["live_view_app", registerLiveViewApp], ["projects", registerProjectCapabilities], ["api_keys", registerAPIKeyCapabilities], ["browser_pools", registerBrowserPoolCapabilities], @@ -35,7 +43,7 @@ const mcpToolRegistrations = [ ["shell", registerShellTool], ["playwright", registerPlaywrightTool], ["replays", registerReplayTools], - ["auth_connections", registerAuthConnectionTools], + ["auth_connections", registerManagedAuthCapabilities], ["credentials", registerCredentialTools], ["credential_providers", registerCredentialProviderTools], ] as const satisfies readonly (readonly [string, RegisterMcpToolset])[]; @@ -51,6 +59,8 @@ const standaloneToolsetAliases: Partial> = { execute_playwright_code: "playwright", exec_command: "shell", browser_utilities: "browser_curl", + show_browser_live_view: "live_view_app", + open_auth_login: "auth_connections", }; function isMcpToolset(value: string): value is McpToolset { diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts new file mode 100644 index 0000000..15ed96b --- /dev/null +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from "bun:test"; +import type { KernelClient } from "@/lib/mcp/kernel-client"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAuthConnectionTools } from "@/lib/mcp/tools/auth-connections"; +import { + beginAuthLogin, + deriveAuthNextAction, + toSafeAuthConnection, +} from "@/lib/mcp/tools/managed-auth-state"; +import type { ManagedAuth } from "@onkernel/sdk/resources/auth/connections"; + +function connection(overrides: Partial = {}): ManagedAuth { + return { + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "NEEDS_AUTH", + record_session: false, + save_credentials: true, + credential: { name: "secret-credential-ref" }, + hosted_url: "https://managed-auth.onkernel.com/login/conn_1?code=secret", + live_view_url: "https://live.example/secret", + browser_session_id: "browser_secret", + discovered_fields: [ + { + label: "Password", + name: "password", + selector: "#password", + type: "password", + }, + ], + website_error: "untrusted website text", + ...overrides, + }; +} + +function fakeClient({ + initial = connection(), + created, + login, + createError, + loginError, +}: { + initial?: ManagedAuth; + created?: ManagedAuth; + login?: { + id: string; + flow_type: "LOGIN" | "REAUTH"; + flow_expires_at: string; + hosted_url: string; + handoff_code?: string; + }; + createError?: unknown; + loginError?: unknown; +} = {}) { + const calls = { create: 0, retrieve: 0, login: 0 }; + const client = { + auth: { + connections: { + create: async () => { + calls.create++; + if (createError) throw createError; + return created ?? initial; + }, + retrieve: async () => { + calls.retrieve++; + return initial; + }, + login: async () => { + calls.login++; + if (loginError) throw loginError; + return ( + login ?? { + id: initial.id, + flow_type: "LOGIN" as const, + flow_expires_at: "2099-01-01T00:00:00Z", + hosted_url: `https://managed-auth.onkernel.com/login/${initial.id}?code=handoff-secret`, + handoff_code: "handoff-secret", + } + ); + }, + }, + }, + } as unknown as KernelClient; + return { client, calls }; +} + +const forbiddenKeys = [ + "handoff_code", + "hosted_url", + "live_view_url", + "jwt", + "authorization", + "credential", + "discovered_fields", + "mfa_options", + "pending_sso_buttons", + "website_error", + "browser_session_id", +]; + +function assertNoSecrets(value: unknown) { + const json = JSON.stringify(value).toLowerCase(); + for (const key of forbiddenKeys) expect(json).not.toContain(`"${key}"`); + expect(json).not.toContain("secret"); + expect(json).not.toContain("untrusted website text"); +} + +describe("managed-auth safe responses", () => { + test("keeps discovery on manage_auth_connections and removes secret login inputs", () => { + let schema: Record | undefined; + const server = { + tool( + _name: string, + _description: string, + inputSchema: Record, + ) { + schema = inputSchema; + }, + } as unknown as McpServer; + registerAuthConnectionTools(server); + expect(schema?.action.safeParse("list").success).toBe(true); + expect(schema?.action.safeParse("get").success).toBe(true); + expect(schema?.action.safeParse("create").success).toBe(false); + expect(schema?.action.safeParse("delete").success).toBe(false); + expect(schema?.action.safeParse("login").success).toBe(false); + expect(schema?.action.safeParse("submit").success).toBe(false); + expect(schema?.fields).toBeUndefined(); + expect(schema?.mfa_option_id).toBeUndefined(); + expect(schema?.sso_button_selector).toBeUndefined(); + expect(schema?.allowed_domains).toBeUndefined(); + expect(schema?.login_url).toBeUndefined(); + expect(schema?.credential_name).toBeUndefined(); + expect(schema?.credential_provider).toBeUndefined(); + expect(schema?.credential_path).toBeUndefined(); + }); + + test("allowlists fields and replaces raw errors", () => { + const safe = toSafeAuthConnection( + connection({ + flow_status: "FAILED", + error_code: "login_failed", + error_message: "raw site/API failure with secret", + }), + ); + expect(safe.error_code).toBe("login_failed"); + expect(safe.error_message).toBe( + "Managed authentication failed. Retry the secure login flow.", + ); + assertNoSecrets(safe); + }); + + test("steers none, one authenticated, one needs-auth, multiple, and incomplete pages", () => { + const none = deriveAuthNextAction({ + items: [], + hasMore: false, + nextOffset: null, + domainFilter: "example.com", + }); + expect(none.selection.outcome).toBe("none"); + expect(none.next_action?.arguments).toEqual({ + mode: "new_login", + domain: "example.com", + }); + expect(none.next_action?.required_user_input).toEqual(["profile_name"]); + + const safe = toSafeAuthConnection(connection()); + const needsAuth = deriveAuthNextAction({ + items: [safe], + hasMore: false, + nextOffset: null, + domainFilter: "example.com", + }); + expect(needsAuth.next_action?.arguments).toEqual({ + mode: "reauth", + connection_id: "conn_1", + }); + + const authenticated = deriveAuthNextAction({ + items: [{ ...safe, status: "AUTHENTICATED", flow_status: "FAILED" }], + hasMore: false, + nextOffset: null, + domainFilter: "example.com", + }); + expect(authenticated.next_action?.tool).toBe("manage_browsers"); + expect(authenticated.next_action?.arguments.profile_name).toBe("work"); + + const multiple = deriveAuthNextAction({ + items: [safe, { ...safe, id: "conn_2", profile_name: "personal" }], + hasMore: false, + nextOffset: null, + domainFilter: "example.com", + }); + expect(multiple.selection.outcome).toBe("multiple"); + expect(multiple.next_action?.required_user_input).toEqual([ + "connection_id", + ]); + + const incomplete = deriveAuthNextAction({ + items: [safe], + hasMore: true, + nextOffset: 10, + domainFilter: "example.com", + profileFilter: "work", + }); + expect(incomplete.selection.outcome).toBe("page_incomplete"); + expect(incomplete.next_action?.arguments.offset).toBe(10); + expect(incomplete.next_action?.arguments.profile_name).toBe("work"); + + const finalContinuation = deriveAuthNextAction({ + items: [safe], + hasMore: false, + nextOffset: null, + offset: 1, + domainFilter: "example.com", + }); + expect(finalContinuation.selection.outcome).toBe("page_incomplete"); + expect(finalContinuation.next_action?.tool).toBe("ask_user"); + expect( + finalContinuation.next_action?.arguments.include_matches_from_prior_pages, + ).toBe(true); + }); +}); + +describe("managed-auth start/resume state machine", () => { + test("does not restart a live unexpired flow", async () => { + const initial = connection({ + flow_status: "IN_PROGRESS", + flow_expires_at: "2099-01-01T00:00:00Z", + }); + const { client, calls } = fakeClient({ initial }); + const result = await beginAuthLogin(client, { + mode: "reauth", + connection_id: initial.id, + }); + expect(result.state).toBe("observing"); + expect(calls.login).toBe(0); + expect(result.handoff_code).toBeUndefined(); + expect(result.hosted_url).toBeUndefined(); + assertNoSecrets(result.connection); + }); + + test("recovers duplicate create and returns authenticated without login", async () => { + const initial = connection({ status: "AUTHENTICATED" }); + const { client, calls } = fakeClient({ + initial, + createError: { + status: 409, + error: { existing_id: initial.id }, + }, + }); + const result = await beginAuthLogin(client, { + mode: "new_login", + domain: "example.com", + profile_name: "work", + }); + expect(result.state).toBe("already_authenticated"); + expect(calls.retrieve).toBe(1); + expect(calls.login).toBe(0); + }); + + test("explicit reauth starts login even when authenticated", async () => { + const initial = connection({ status: "AUTHENTICATED" }); + const { client, calls } = fakeClient({ initial }); + const result = await beginAuthLogin(client, { + mode: "reauth", + connection_id: initial.id, + }); + expect(calls.login).toBe(1); + expect(result.started_new_flow).toBe(true); + expect(result.state).toBe("embedded_ready"); + }); + + test("surfaces pending-session conflicts instead of observing an orphan", async () => { + const initial = connection({ + flow_status: "IN_PROGRESS", + flow_expires_at: "2000-01-01T00:00:00Z", + }); + const { client, calls } = fakeClient({ + initial, + loginError: { + status: 409, + error: { code: "too_many_pending_sessions" }, + }, + }); + await expect( + beginAuthLogin(client, { + mode: "reauth", + connection_id: initial.id, + }), + ).rejects.toThrow("Too many managed-auth sessions are pending"); + expect(calls.login).toBe(1); + expect(calls.retrieve).toBe(1); + }); +}); diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 73206f1..e4a6ed3 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -2,200 +2,59 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; import { - errorResponse, - jsonResponse, - paginatedJsonResponse, - textResponse, - toolErrorResponse, -} from "@/lib/mcp/responses"; + deriveAuthNextAction, + toSafeAuthConnection, +} from "@/lib/mcp/tools/managed-auth-state"; +import { errorResponse } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +function safeJsonResponse(value: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(value, null, 2), + }, + ], + structuredContent: value as Record, + }; +} + export function registerAuthConnectionTools(server: McpServer) { - // manage_auth_connections -- Manage Kernel managed auth connections server.tool( "manage_auth_connections", - 'Manage Kernel managed auth connections for keeping a profile logged into a third-party site. Use "create" to start managing auth for a profile + domain (optionally referencing a stored credential), "login" to begin a login flow (returns a hosted_url to share with the user, plus live_view_url to watch), "submit" to provide field values or pick an MFA option when a flow is awaiting input, "get" to poll flow state, "list" to see connections, or "delete" to remove one.', + 'Discover sanitized Kernel managed-auth connections. Start every protected-site task with action="list" and domain_filter. Reason over all pages: use an AUTHENTICATED profile with manage_browsers, ask the user to choose when multiple profiles match, or ask for consent before calling open_auth_login for a new login/re-auth. Never ask the user to send passwords, OTPs, or MFA values in conversation. Connection creation, deletion, login, and credential mutation are intentionally outside this model-facing discovery tool. Actions: list, get.', { - action: z - .enum(["create", "list", "get", "delete", "login", "submit"]) - .describe("Operation to perform."), + action: z.enum(["list", "get"]).describe("Discovery operation."), id: z .string() - .describe( - "Auth connection ID. Required for get, delete, login, submit.", - ) - .optional(), - domain: z - .string() - .describe("(create) Target domain (e.g. 'netflix.com').") + .describe("Auth connection ID. Required for get.") .optional(), profile_name: z .string() - .describe( - "(create) Profile to manage auth for. (list) Filter by profile_name.", - ) - .optional(), - allowed_domains: z - .array(z.string()) - .describe( - "(create) Additional domains valid for this auth flow. Common SSO providers (Google, Microsoft, Okta, Auth0, Apple, GitHub, Facebook, LinkedIn, Cognito, OneLogin, Ping) are allowed by default.", - ) - .optional(), - credential_name: z - .string() - .describe( - "(create) Name of a pre-stored Kernel credential to use for automatic login.", - ) - .optional(), - credential_provider: z - .string() - .describe( - "(create) External credential provider name (e.g. '1password'). Use with credential_path or credential_auto.", - ) - .optional(), - credential_path: z - .string() - .describe( - "(create) Provider-specific item path (e.g. 'VaultName/ItemName').", - ) - .optional(), - credential_auto: z - .boolean() - .describe( - "(create) If true, the provider auto-looks up credentials by domain.", - ) + .describe("(list) Filter by profile_name.") .optional(), - login_url: z + domain_filter: z .string() .describe( - "(create) Optional explicit login page URL to skip discovery.", - ) - .optional(), - health_check_interval: z - .number() - .int() - .describe( - "(create) Seconds between automatic re-auth checks. Plan-dependent minimum, max 86400.", - ) - .optional(), - save_credentials: z - .boolean() - .describe( - "(create) Save credentials after each successful login. Default true.", + "(list) Domain to match. Always set this when discovering a profile for a protected site.", ) .optional(), - proxy_id: z - .string() - .describe("(create, login) Proxy ID to route the auth flow through.") - .optional(), - proxy_name: z - .string() - .describe("(create, login) Proxy name to route the auth flow through.") - .optional(), - domain_filter: z.string().describe("(list) Filter by domain.").optional(), ...paginationParams, - fields: z - .record(z.string(), z.string()) - .describe( - "(submit) Map of field name to value (e.g. { mfa_code: '123456' }). Look at discovered_fields from `get` to know what to provide.", - ) - .optional(), - mfa_option_id: z - .string() - .describe( - "(submit) ID of the MFA option to use, from mfa_options on the connection.", - ) - .optional(), - sso_button_selector: z - .string() - .describe( - "(submit) XPath of an SSO button to click instead of submitting fields.", - ) - .optional(), }, { - title: "Manage Kernel managed auth connections", - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, + title: "Discover Kernel managed auth connections", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, openWorldHint: true, }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient(extra.authInfo.token); - const buildProxy = () => - params.proxy_id || params.proxy_name - ? { - ...(params.proxy_id && { id: params.proxy_id }), - ...(params.proxy_name && { name: params.proxy_name }), - } - : undefined; - try { switch (params.action) { - case "create": { - if (!params.domain || !params.profile_name) { - return errorResponse( - "Error: domain and profile_name are required for create.", - ); - } - const hasName = !!params.credential_name; - const hasProvider = !!params.credential_provider; - const hasPath = !!params.credential_path; - const autoTrue = params.credential_auto === true; - if (hasName && (hasProvider || hasPath || autoTrue)) { - return errorResponse( - "Error: credential_name cannot be combined with credential_provider, credential_path, or credential_auto. Use one of: { credential_name } for Kernel credentials, { credential_provider, credential_path } for an external provider item, or { credential_provider, credential_auto: true } for provider domain lookup.", - ); - } - if ((hasPath || autoTrue) && !hasProvider) { - return errorResponse( - "Error: credential_path and credential_auto require credential_provider.", - ); - } - if (hasPath && autoTrue) { - return errorResponse( - "Error: credential_path and credential_auto: true are alternatives — provide exactly one.", - ); - } - if (hasProvider && !hasPath && !autoTrue) { - return errorResponse( - "Error: credential_provider requires either credential_path or credential_auto: true.", - ); - } - const credential = - hasName || hasProvider - ? { - ...(hasName && { name: params.credential_name }), - ...(hasProvider && { - provider: params.credential_provider, - }), - ...(hasPath && { path: params.credential_path }), - ...(autoTrue && { auto: true }), - } - : undefined; - const proxy = buildProxy(); - const connection = await client.auth.connections.create({ - domain: params.domain, - profile_name: params.profile_name, - ...(params.allowed_domains && { - allowed_domains: params.allowed_domains, - }), - ...(credential && { credential }), - ...(params.login_url && { login_url: params.login_url }), - ...(params.health_check_interval !== undefined && { - health_check_interval: params.health_check_interval, - }), - ...(params.save_credentials !== undefined && { - save_credentials: params.save_credentials, - }), - ...(proxy && { proxy }), - }); - if (!connection) - return errorResponse("Failed to create auth connection"); - return jsonResponse(connection); - } case "list": { const page = await client.auth.connections.list({ ...(params.profile_name && { profile_name: params.profile_name }), @@ -203,62 +62,43 @@ export function registerAuthConnectionTools(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - return paginatedJsonResponse(page); + const items = page.getPaginatedItems().map(toSafeAuthConnection); + const hasMore = page.hasNextPage(); + const nextOffset = page.next_offset ?? null; + const steering = deriveAuthNextAction({ + items, + hasMore, + nextOffset, + offset: params.offset, + domainFilter: params.domain_filter, + profileFilter: params.profile_name, + }); + return safeJsonResponse({ + items, + has_more: hasMore, + next_offset: nextOffset, + ...steering, + }); } case "get": { - if (!params.id) + if (!params.id) { return errorResponse("Error: id is required for get."); + } const connection = await client.auth.connections.retrieve( params.id, ); - return jsonResponse(connection); - } - case "delete": { - if (!params.id) - return errorResponse("Error: id is required for delete."); - await client.auth.connections.delete(params.id); - return textResponse("Auth connection deleted successfully"); - } - case "login": { - if (!params.id) - return errorResponse("Error: id is required for login."); - const proxy = buildProxy(); - const response = await client.auth.connections.login( - params.id, - proxy ? { proxy } : undefined, - ); - return jsonResponse(response); - } - case "submit": { - if (!params.id) - return errorResponse("Error: id is required for submit."); - const hasFields = - !!params.fields && Object.keys(params.fields).length > 0; - if ( - !hasFields && - !params.mfa_option_id && - !params.sso_button_selector - ) - return errorResponse( - "Error: submit requires at least one of fields (non-empty), mfa_option_id, or sso_button_selector.", - ); - const response = await client.auth.connections.submit(params.id, { - ...(hasFields && { fields: params.fields }), - ...(params.mfa_option_id && { - mfa_option_id: params.mfa_option_id, - }), - ...(params.sso_button_selector && { - sso_button_selector: params.sso_button_selector, - }), + return safeJsonResponse({ + connection: toSafeAuthConnection(connection), + instruction: + connection.status === "AUTHENTICATED" + ? "Authentication is verified. Use this profile_name when creating the browser." + : "Do not continue the protected action. Ask for consent, then use open_auth_login to authenticate securely.", }); - return jsonResponse(response); } } - } catch (error) { - return toolErrorResponse( - "manage_auth_connections", - params.action, - error, + } catch { + return errorResponse( + `Managed-auth ${params.action} failed. Retry or choose a different recovery option.`, ); } }, diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts new file mode 100644 index 0000000..afb5d87 --- /dev/null +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; +import { + MANAGED_AUTH_MIME_TYPE, + MANAGED_AUTH_RESOURCE_URI, + managedAuthResourceMeta, + registerAuthLoginApp, +} from "@/lib/mcp/tools/auth-login-app"; + +type ToolRegistration = { + config: Record; + handler: (params: any, extra: any) => Promise; +}; + +function captureRegistration() { + const tools = new Map(); + let resource: + | { + uri: string; + config: Record; + handler: (uri: URL) => Promise; + } + | undefined; + const appCapability = { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [MANAGED_AUTH_MIME_TYPE], + }, + }, + }; + const registrationHandle = () => ({ + enable() {}, + disable() {}, + }); + const server = { + server: { + getClientCapabilities: () => appCapability, + }, + registerResource( + _name: string, + uri: string, + config: Record, + handler: (uri: URL) => Promise, + ) { + resource = { uri, config, handler }; + return registrationHandle(); + }, + registerTool( + name: string, + config: Record, + handler: ToolRegistration["handler"], + ) { + tools.set(name, { config, handler }); + return registrationHandle(); + }, + } as unknown as McpServer; + registerAuthLoginApp(server); + return { + tools, + get resource() { + return resource; + }, + }; +} + +describe("managed-auth MCP App registration", () => { + process.env.MCP_APP_SIGNING_SECRET = "test-app-signing-secret"; + + test("uses exact resource MIME, URI, and CSP on registration and read", async () => { + process.env.MANAGED_AUTH_APP_ORIGIN = "http://localhost:3002"; + const captured = captureRegistration(); + expect(captured.resource?.uri).toBe(MANAGED_AUTH_RESOURCE_URI); + expect(captured.resource?.config.mimeType).toBe(MANAGED_AUTH_MIME_TYPE); + expect(captured.resource?.config._meta).toEqual(managedAuthResourceMeta()); + const read = await captured.resource!.handler( + new URL(MANAGED_AUTH_RESOURCE_URI), + ); + expect(read.contents[0].mimeType).toBe(MANAGED_AUTH_MIME_TYPE); + expect(read.contents[0]._meta).toEqual(captured.resource?.config._meta); + expect(read.contents[0]._meta.ui.csp.connectDomains).toEqual([ + "http://localhost:3002", + ]); + expect(read.contents[0]._meta.ui.csp.frameDomains).toBeUndefined(); + expect(read.contents[0]._meta.ui.csp.resourceDomains).toBeUndefined(); + }); + + test("links only the public launcher and hides helpers from the model", () => { + const { tools } = captureRegistration(); + expect(tools.get("open_auth_login")?.config._meta.ui).toEqual({ + resourceUri: MANAGED_AUTH_RESOURCE_URI, + visibility: ["model", "app"], + }); + expect(tools.get("open_auth_login")?.config._meta["ui/resourceUri"]).toBe( + MANAGED_AUTH_RESOURCE_URI, + ); + expect(tools.get("begin_auth_login")?.config._meta.ui.visibility).toEqual([ + "app", + ]); + expect( + tools.get("get_auth_login_status")?.config._meta.ui.visibility, + ).toEqual(["app"]); + expect( + tools.get("delete_auth_login_connection")?.config._meta.ui.visibility, + ).toEqual(["app"]); + }); + + test("normal launcher creates no backend flow or managed-auth handoff", async () => { + const { tools } = captureRegistration(); + const result = await tools.get("open_auth_login")!.handler( + { + mode: "new_login", + domain: "example.com", + profile_name: "work", + text_only: false, + }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.structuredContent).toEqual({ + kind: "kernel.managed_auth.launcher", + version: 1, + mode: "new_login", + connection: { domain: "example.com", profile_name: "work" }, + text_only: false, + app_capability: expect.any(String), + }); + expect(JSON.stringify(result)).not.toContain("?code="); + expect(JSON.stringify(result)).not.toContain("handoff_code"); + expect(JSON.stringify(result)).not.toContain("hosted_url"); + expect(result._meta.auth_login_launcher.app_capability).toBeString(); + expect(JSON.stringify(result.content)).not.toContain("app_capability"); + expect(result.structuredContent.app_capability).toBe( + result._meta.auth_login_launcher.app_capability, + ); + }); + + test("destructive app-only cleanup rejects calls without the private launcher capability", async () => { + const { tools } = captureRegistration(); + const result = await tools.get("delete_auth_login_connection")!.handler( + { + connection_id: "connection-id", + app_capability: "not-valid", + }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("authorization is invalid"); + }); + + test("bundle is self-contained and has no hosted iframe", () => { + expect(MANAGED_AUTH_APP_HTML.length).toBeGreaterThan(100_000); + expect(MANAGED_AUTH_APP_HTML).not.toContain(" { + expect(MANAGED_AUTH_APP_HTML).toContain("profile_name"); + expect(MANAGED_AUTH_APP_HTML).not.toContain("connectionId"); + expect(MANAGED_AUTH_APP_HTML).not.toContain("claimedOutcome"); + expect(MANAGED_AUTH_APP_HTML).not.toContain("resumeId"); + }); +}); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts new file mode 100644 index 0000000..38ced89 --- /dev/null +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -0,0 +1,397 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; +import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + AuthLoginStartError, + beginAuthLogin, + type AuthLoginInput, + toSafeAuthConnection, + validateAuthLoginInput, +} from "@/lib/mcp/tools/managed-auth-state"; +import { errorResponse } from "@/lib/mcp/responses"; + +export const MANAGED_AUTH_RESOURCE_URI = + "ui://kernel/managed-auth-login-v4.html"; +export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; + +export function managedAuthAppOrigin(): string { + const configured = + process.env.MANAGED_AUTH_APP_ORIGIN ?? "https://mcp.onkernel.com"; + return new URL(configured).origin; +} + +export function managedAuthResourceMeta() { + return { + ui: { + prefersBorder: true, + csp: { + connectDomains: [managedAuthAppOrigin()], + }, + }, + }; +} + +const APP_CAPABILITY_TTL_MS = 60 * 60 * 1000; + +function appSigningSecret(): string { + const secret = + process.env.MCP_APP_SIGNING_SECRET ?? process.env.CLERK_SECRET_KEY; + if (!secret) throw new Error("MCP App signing secret is not configured"); + return secret; +} + +function authTokenHash(authToken: string): string { + return createHash("sha256").update(authToken).digest("base64url"); +} + +function issueAppCapability(authToken: string): string { + const payload = Buffer.from( + JSON.stringify({ + auth: authTokenHash(authToken), + exp: Date.now() + APP_CAPABILITY_TTL_MS, + }), + ).toString("base64url"); + const signature = createHmac("sha256", appSigningSecret()) + .update(payload) + .digest("base64url"); + return `${payload}.${signature}`; +} + +function validAppCapability(capability: string, authToken: string): boolean { + const [payload, signature, extra] = capability.split("."); + if (!payload || !signature || extra) return false; + const expected = createHmac("sha256", appSigningSecret()) + .update(payload) + .digest(); + let actual: Buffer; + try { + actual = Buffer.from(signature, "base64url"); + } catch { + return false; + } + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return false; + } + try { + const claims = JSON.parse(Buffer.from(payload, "base64url").toString()) as { + auth?: unknown; + exp?: unknown; + }; + return ( + claims.auth === authTokenHash(authToken) && + typeof claims.exp === "number" && + claims.exp > Date.now() + ); + } catch { + return false; + } +} + +const authLoginInputSchema = { + mode: z.enum(["new_login", "reauth"]), + connection_id: z.string().optional(), + domain: z.string().optional(), + profile_name: z.string().optional(), + save_credentials: z.boolean().optional(), + proxy_id: z.string().optional(), + proxy_name: z.string().optional(), +}; + +function inputFromParams(params: AuthLoginInput): AuthLoginInput { + return { + mode: params.mode, + ...(params.connection_id && { connection_id: params.connection_id }), + ...(params.domain && { domain: params.domain }), + ...(params.profile_name && { profile_name: params.profile_name }), + ...(params.save_credentials !== undefined && { + save_credentials: params.save_credentials, + }), + ...(params.proxy_id && { proxy_id: params.proxy_id }), + ...(params.proxy_name && { proxy_name: params.proxy_name }), + }; +} + +export function registerAuthLoginApp(server: McpServer) { + const resourceMeta = managedAuthResourceMeta(); + + server.registerResource( + "kernel-managed-auth-login", + MANAGED_AUTH_RESOURCE_URI, + { + title: "Kernel Managed Authentication", + description: + "Secure interactive Kernel login panel. Credentials and MFA stay inside the panel and never enter the MCP conversation.", + mimeType: MANAGED_AUTH_MIME_TYPE, + _meta: resourceMeta, + }, + async (uri) => ({ + contents: [ + { + uri: uri.toString(), + mimeType: MANAGED_AUTH_MIME_TYPE, + text: MANAGED_AUTH_APP_HTML, + _meta: resourceMeta, + }, + ], + }), + ); + + server.registerTool( + "open_auth_login", + { + title: "Open secure managed-auth login", + description: + 'Display a secure Kernel login panel after the user consents. Before calling: use manage_auth_connections(action="list", domain_filter=...) across all pages, reason about the result, and ask the user which profile to use if needed. Never ask for passwords, credentials, OTPs, or MFA values in conversation. The user enters them only in the panel. After completion, re-fetch the connection and continue only when status is AUTHENTICATED. This call does not create or start a flow until the user clicks Continue. Use text_only=true only after the user confirms no App appeared; that compatibility fallback exposes a capability-bearing hosted URL as user-audience text.', + inputSchema: { + ...authLoginInputSchema, + text_only: z.boolean().default(false), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { + ui: { + resourceUri: MANAGED_AUTH_RESOURCE_URI, + visibility: ["model", "app"], + }, + "ui/resourceUri": MANAGED_AUTH_RESOURCE_URI, + }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const input = inputFromParams(params); + const validationError = validateAuthLoginInput(input); + if (validationError) return errorResponse(`Error: ${validationError}`); + const client = createKernelClient(extra.authInfo.token); + + try { + if (params.text_only) { + const result = await beginAuthLogin(client, input); + const content: Array<{ + type: "text"; + text: string; + annotations?: { audience: ["user"] }; + }> = [ + { + type: "text", + text: `Secure managed authentication is ${result.state === "already_authenticated" ? "already complete" : "ready"} for connection ${result.connection.id}. Expiry: ${result.connection.flow_expires_at ?? "not applicable"}. Do not claim success from this response. After the user completes login, re-fetch with manage_auth_connections and continue only if status is AUTHENTICATED.`, + }, + ]; + if (result.hosted_url) { + content.push({ + type: "text", + text: result.hosted_url, + annotations: { audience: ["user"] }, + }); + } + return { + content, + structuredContent: { + kind: "kernel.managed_auth.text_fallback", + version: 1, + connection_id: result.connection.id, + flow_expires_at: result.connection.flow_expires_at, + state: result.state, + }, + }; + } + + const connection = + input.mode === "reauth" + ? toSafeAuthConnection( + await client.auth.connections.retrieve(input.connection_id!), + ) + : { + domain: input.domain!, + profile_name: input.profile_name!, + }; + const appCapability = issueAppCapability(extra.authInfo.token); + return { + content: [ + { + type: "text" as const, + text: "A secure Kernel login panel was requested. Do not claim that it rendered or that authentication succeeded. Never ask for credentials in conversation. Wait for the App to report completion, then re-fetch and verify the connection before continuing.", + }, + ], + structuredContent: { + kind: "kernel.managed_auth.launcher", + version: 1, + mode: input.mode, + connection, + text_only: false, + // MCP Apps structuredContent is delivered to the View but is not + // added to model context. Claude currently strips tool-result + // _meta from launcher notifications, so duplicate this short-lived, + // API-token-bound capability here for host compatibility. + app_capability: appCapability, + }, + _meta: { + auth_login_launcher: { + app_capability: appCapability, + }, + }, + }; + } catch (error) { + return errorResponse( + error instanceof AuthLoginStartError + ? error.safeMessage + : "Managed authentication could not be prepared. Retry the secure login flow.", + ); + } + }, + ); + + server.registerTool( + "begin_auth_login", + { + title: "Begin secure managed authentication (app-only)", + description: + "Start or resume the secure managed-auth flow after the App user clicks Continue.", + inputSchema: authLoginInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const input = inputFromParams(params); + const validationError = validateAuthLoginInput(input); + if (validationError) return errorResponse(`Error: ${validationError}`); + const client = createKernelClient(extra.authInfo.token); + + try { + const result = await beginAuthLogin(client, input); + const appPrivate = { + ...(result.handoff_code && { + handoff_code: result.handoff_code, + }), + ...(result.hosted_url && { hosted_url: result.hosted_url }), + relay_base_url: `${managedAuthAppOrigin()}/managed-auth-proxy`, + }; + return { + content: [ + { + type: "text" as const, + text: "Secure managed authentication is ready.", + }, + ], + structuredContent: { + kind: "kernel.managed_auth.begin", + version: 1, + state: result.state, + connection: result.connection, + started_new_flow: result.started_new_flow, + resume_id: result.resume_id, + // This helper is visibility:["app"] and cannot be called by the + // model on compliant hosts. Duplicate App-private flow material + // here because Claude may omit tool-result _meta. + app_private: appPrivate, + }, + _meta: { + auth_login: appPrivate, + }, + }; + } catch (error) { + return errorResponse( + error instanceof AuthLoginStartError + ? error.safeMessage + : "Managed authentication could not start. Close the panel and retry.", + ); + } + }, + ); + + server.registerTool( + "get_auth_login_status", + { + title: "Get managed-auth login status (app-only)", + description: + "Read sanitized managed-auth status for the secure login App.", + inputSchema: { + connection_id: z.string(), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + try { + const connection = toSafeAuthConnection( + await client.auth.connections.retrieve(params.connection_id), + ); + return { + content: [ + { + type: "text" as const, + text: "Managed authentication status refreshed.", + }, + ], + structuredContent: { + kind: "kernel.managed_auth.status", + version: 1, + connection, + }, + }; + } catch { + return errorResponse( + "Managed authentication status could not be refreshed. Retry shortly.", + ); + } + }, + ); + + server.registerTool( + "delete_auth_login_connection", + { + title: "Delete managed-auth connection (app-only)", + description: + "Delete the managed-auth connection created by the secure App, including QA cleanup.", + inputSchema: { + connection_id: z.string(), + app_capability: z.string(), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + if (!validAppCapability(params.app_capability, extra.authInfo.token)) { + return errorResponse("Secure App authorization is invalid or expired."); + } + const client = createKernelClient(extra.authInfo.token); + try { + await client.auth.connections.delete(params.connection_id); + return { + content: [ + { + type: "text" as const, + text: "Managed-auth connection deleted.", + }, + ], + }; + } catch { + return errorResponse("Managed-auth connection could not be deleted."); + } + }, + ); +} diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 6865fe2..60def23 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -266,6 +266,7 @@ async function readBrowserTelemetry( function browserSessionNextActions(sessionId: string) { return [ + `Use show_browser_live_view with session_id "${sessionId}" to display an embedded live view of this browser to the user.`, `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, `Use manage_browsers with action "get" and session_id "${sessionId}" for full browser details.`, `Use manage_browsers with action "delete" and session_id "${sessionId}" when the session is no longer needed.`, @@ -345,7 +346,7 @@ export function registerBrowserCapabilities(server: McpServer) { // manage_browsers -- Manage browser sessions and read archived telemetry server.tool( "manage_browsers", - 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', + 'Manage browser sessions and their archived telemetry. Before creating a browser for a site that requires login, first call manage_auth_connections(action="list", domain_filter=...), inspect all pages, ask the user to choose if multiple profiles match, and use the returned authenticated profile_name. Never create a fresh browser when an authenticated managed-auth profile is available. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', { action: z .enum(["create", "update", "list", "get", "get_telemetry", "delete"]) @@ -395,7 +396,7 @@ export function registerBrowserCapabilities(server: McpServer) { profile_name: z .string() .describe( - "(create, update) Profile name to load saved cookies/logins. Cannot use with profile_id.", + "(create, update) Profile name to load saved cookies/logins. For protected sites, obtain this from manage_auth_connections after domain-filtered discovery and user selection. Continue only when that connection is AUTHENTICATED. Cannot use with profile_id.", ) .optional(), profile_id: z diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts new file mode 100644 index 0000000..2348a3d --- /dev/null +++ b/src/lib/mcp/tools/live-view-app.ts @@ -0,0 +1,567 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorResponse, toolErrorResponse } from "@/lib/mcp/responses"; + +/** + * MCP Apps (SEP-1865) prototype: an embedded view of a Kernel browser + * session, rendered inline in MCP Apps-capable hosts (Claude Desktop, etc). + * + * The `show_browser_live_view` tool is linked (via `_meta.ui.resourceUri`) to + * a `ui://` HTML resource. Hosts that support the `io.modelcontextprotocol/ui` + * extension fetch that resource, render it in a sandboxed iframe, and deliver + * the tool result to it over postMessage JSON-RPC. + * + * The view renders read-only snapshots of the browser: it polls the app-only + * `capture_live_view_frame` tool through the host and swaps PNG frames into an + * . We deliberately do NOT iframe the real live view — hosts like Claude + * block nested third-party iframes (frameDomains is restricted pending + * security review), and snapshots work everywhere with no external origins. + * The full interactive live view is still one click away via ui/open-link. + * + * Hosts without MCP Apps support just see a normal tool that returns the live + * view URLs as text. + */ + +// Bumping the version in the URI busts host-side resource caches (hosts MAY +// prefetch and cache UI resources); the unversioned legacy URI stays +// registered below for hosts still holding cached tool metadata. +export const LIVE_VIEW_RESOURCE_URI = "ui://kernel/live-view-v2.html"; + +const LIVE_VIEW_LEGACY_RESOURCE_URI = "ui://kernel/live-view.html"; + +export const LIVE_VIEW_MIME_TYPE = "text/html;profile=mcp-app"; + +// No external origins needed: snapshots arrive as data: URIs via host-proxied +// tool calls, which the spec's restrictive default CSP already permits. +const LIVE_VIEW_UI_META = { + prefersBorder: true, +}; + +const SNAPSHOT_INTERVAL_MS = 2500; +const SNAPSHOT_RETRY_MS = 4000; +const SNAPSHOT_MAX_FAILURES = 5; + +// The view is a single self-contained HTML document speaking raw postMessage +// JSON-RPC per the MCP Apps spec (no bundler / SDK build step needed). +const LIVE_VIEW_HTML = /* html */ ` + + + + + Kernel Browser Live View + + + +
+
+ Kernel browser + connecting + + + +
+
+
Waiting for browser…
+ +
+
session
+
+
+ + + +`; + +export function registerLiveViewApp(server: McpServer) { + const readLiveViewResource = async (uri: URL) => ({ + contents: [ + { + uri: uri.toString(), + mimeType: LIVE_VIEW_MIME_TYPE, + text: LIVE_VIEW_HTML, + _meta: { ui: LIVE_VIEW_UI_META }, + }, + ], + }); + + for (const [name, uri] of [ + ["kernel-live-view", LIVE_VIEW_RESOURCE_URI], + ["kernel-live-view-legacy", LIVE_VIEW_LEGACY_RESOURCE_URI], + ] as const) { + server.registerResource( + name, + uri, + { + title: "Kernel Browser Live View", + description: + "Interactive view that shows read-only snapshots of a Kernel browser session inline in the conversation.", + mimeType: LIVE_VIEW_MIME_TYPE, + _meta: { ui: LIVE_VIEW_UI_META }, + }, + readLiveViewResource, + ); + } + + server.registerTool( + "show_browser_live_view", + { + title: "Show browser live view", + description: + "Render an embedded, read-only view of a Kernel browser session directly in the chat so the user can watch automation happen in real time (refreshing snapshots). Call this once right after creating a (non-headless) browser session, before starting to control it; the view keeps refreshing while you work. On hosts without MCP Apps support this returns the live view URLs as text instead.", + inputSchema: { + session_id: z + .string() + .describe("Browser session ID to display (from manage_browsers)."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { + ui: { + resourceUri: LIVE_VIEW_RESOURCE_URI, + visibility: ["model", "app"], + }, + // Deprecated flat form kept for hosts on the pre-GA metadata shape. + "ui/resourceUri": LIVE_VIEW_RESOURCE_URI, + }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + const browser = await client.browsers.retrieve(params.session_id); + if (!browser) { + return errorResponse( + `Browser session "${params.session_id}" not found`, + ); + } + const interactiveUrl = browser.browser_live_view_url; + if (!interactiveUrl) { + return errorResponse( + `Browser session "${params.session_id}" has no live view URL (headless sessions have no live view).`, + ); + } + const readOnlyUrl = new URL(interactiveUrl); + readOnlyUrl.searchParams.set("readOnly", "true"); + + return { + content: [ + { + type: "text" as const, + text: + `Live view for browser session ${params.session_id} is now displayed to the user (read-only snapshots).` + + ` Read-only URL: ${readOnlyUrl.toString()}` + + ` | Interactive URL: ${interactiveUrl}`, + }, + ], + structuredContent: { + session_id: params.session_id, + live_view_url: readOnlyUrl.toString(), + interactive_live_view_url: interactiveUrl, + read_only: true, + }, + }; + } catch (error) { + return toolErrorResponse("show_browser_live_view", "show", error); + } + }, + ); + + // App-only snapshot tool: the render loop of the live view app. Hidden from + // the model on compliant hosts via visibility: ["app"]. + server.registerTool( + "capture_live_view_frame", + { + title: "Capture live view frame (app-only)", + description: + "Capture a PNG snapshot of a browser session for the embedded live view app. Intended for app-initiated calls; agents should use computer_action with a screenshot action instead.", + inputSchema: { + session_id: z + .string() + .describe("Browser session ID to capture a frame from."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + _meta: { + ui: { + visibility: ["app"], + }, + }, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + try { + const screenshotResponse = + await client.browsers.computer.captureScreenshot(params.session_id); + const blob = await screenshotResponse.blob(); + const buffer = Buffer.from(await blob.arrayBuffer()); + return { + content: [ + { + type: "image" as const, + data: buffer.toString("base64"), + mimeType: "image/png", + }, + ], + }; + } catch (error) { + return toolErrorResponse("capture_live_view_frame", "capture", error); + } + }, + ); +} diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts new file mode 100644 index 0000000..f20a324 --- /dev/null +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -0,0 +1,434 @@ +import type { KernelClient } from "@/lib/mcp/kernel-client"; +import type { + LoginResponse, + ManagedAuth, +} from "@onkernel/sdk/resources/auth/connections"; + +export interface SafeAuthConnection { + id: string; + domain: string; + profile_name: string; + status: "AUTHENTICATED" | "NEEDS_AUTH"; + flow_status: + | "IN_PROGRESS" + | "SUCCESS" + | "FAILED" + | "EXPIRED" + | "CANCELED" + | null; + flow_step: + | "DISCOVERING" + | "AWAITING_INPUT" + | "AWAITING_EXTERNAL_ACTION" + | "SUBMITTING" + | "COMPLETED" + | null; + flow_type: "LOGIN" | "REAUTH" | null; + flow_expires_at: string | null; + can_reauth: boolean | null; + can_reauth_reason: string | null; + error_code: string | null; + error_message: string | null; +} + +export type AuthLoginMode = "new_login" | "reauth"; + +export interface AuthLoginInput { + mode: AuthLoginMode; + connection_id?: string; + domain?: string; + profile_name?: string; + save_credentials?: boolean; + proxy_id?: string; + proxy_name?: string; +} + +export interface AuthNextAction { + tool: + | "open_auth_login" + | "manage_auth_connections" + | "manage_browsers" + | "ask_user"; + arguments: Record; + reason: string; + required_user_input?: string[]; +} + +export interface AuthSelection { + domain_filter: string | null; + outcome: "none" | "single" | "multiple" | "page_incomplete"; +} + +export class AuthLoginStartError extends Error { + constructor(public readonly safeMessage: string) { + super(safeMessage); + this.name = "AuthLoginStartError"; + } +} + +export interface AuthSteering { + selection: AuthSelection; + next_action?: AuthNextAction; +} + +export interface BeginAuthLoginResult { + state: + | "embedded_ready" + | "hosted_fallback" + | "observing" + | "already_authenticated"; + connection: SafeAuthConnection; + started_new_flow: boolean; + resume_id: string; + handoff_code?: string; + hosted_url?: string; +} + +const TERMINAL_ERROR_MESSAGES: Partial< + Record, string> +> = { + FAILED: "Managed authentication failed. Retry the secure login flow.", + EXPIRED: "Managed authentication expired. Start a new secure login flow.", + CANCELED: "Managed authentication was canceled. Start again when ready.", +}; + +export function toSafeAuthConnection( + connection: ManagedAuth, +): SafeAuthConnection { + return { + id: connection.id, + domain: connection.domain, + profile_name: connection.profile_name, + status: connection.status, + flow_status: connection.flow_status ?? null, + flow_step: connection.flow_step ?? null, + flow_type: connection.flow_type ?? null, + flow_expires_at: connection.flow_expires_at ?? null, + can_reauth: connection.can_reauth ?? null, + can_reauth_reason: connection.can_reauth_reason ?? null, + error_code: connection.error_code ?? null, + error_message: connection.flow_status + ? (TERMINAL_ERROR_MESSAGES[connection.flow_status] ?? null) + : null, + }; +} + +export function hasLiveAuthFlow( + connection: Pick, + now = new Date(), +): boolean { + if (connection.flow_status !== "IN_PROGRESS") return false; + if (!connection.flow_expires_at) return false; + const expiresAt = Date.parse(connection.flow_expires_at); + return Number.isFinite(expiresAt) && expiresAt > now.getTime(); +} + +export function deriveAuthNextAction({ + items, + hasMore, + nextOffset, + offset, + domainFilter, + profileFilter, +}: { + items: SafeAuthConnection[]; + hasMore: boolean; + nextOffset: number | null; + offset?: number; + domainFilter?: string; + profileFilter?: string; +}): AuthSteering { + const domain_filter = domainFilter ?? null; + + if (hasMore || (offset ?? 0) > 0) { + return { + selection: { domain_filter, outcome: "page_incomplete" }, + next_action: hasMore + ? { + tool: "manage_auth_connections", + arguments: { + action: "list", + ...(domainFilter && { domain_filter: domainFilter }), + ...(profileFilter && { profile_name: profileFilter }), + ...(nextOffset !== null && { offset: nextOffset }), + }, + reason: + "More matching connections exist. Fetch every page and retain the earlier matches; do not infer a unique profile from this page.", + } + : { + tool: "ask_user", + arguments: { + current_page: items.map( + ({ id, profile_name, domain, status }) => ({ + connection_id: id, + profile_name, + domain, + status, + }), + ), + include_matches_from_prior_pages: true, + }, + required_user_input: ["connection_id"], + reason: + "This is a continuation page. Combine it with every retained earlier page before deciding; if the combined result has multiple matches, ask the user to choose.", + }, + }; + } + + if (items.length === 0) { + return { + selection: { domain_filter, outcome: "none" }, + ...(domainFilter && { + next_action: { + tool: "open_auth_login" as const, + arguments: { mode: "new_login", domain: domainFilter }, + required_user_input: ["profile_name"], + reason: + "No connection matches this domain. Ask which profile name to use and get consent before opening secure login.", + }, + }), + }; + } + + if (items.length > 1) { + return { + selection: { domain_filter, outcome: "multiple" }, + next_action: { + tool: "ask_user", + arguments: { + choices: items.map(({ id, profile_name, domain, status }) => ({ + connection_id: id, + profile_name, + domain, + status, + })), + }, + required_user_input: ["connection_id"], + reason: + "Multiple profiles match. Ask the user to choose a profile; do not select one automatically.", + }, + }; + } + + const connection = items[0]; + if (connection.status === "AUTHENTICATED") { + return { + selection: { domain_filter, outcome: "single" }, + next_action: { + tool: "manage_browsers", + arguments: { + action: "create", + profile_name: connection.profile_name, + }, + reason: + "This connection is authenticated. Create the browser with its existing profile; do not start another login.", + }, + }; + } + + return { + selection: { domain_filter, outcome: "single" }, + next_action: { + tool: "open_auth_login", + arguments: { + mode: "reauth", + connection_id: connection.id, + }, + reason: + connection.flow_status === "IN_PROGRESS" + ? "Authentication is already in progress. Ask for consent, then reopen the secure panel to resume or observe it." + : "This profile needs authentication. Ask for consent before opening the secure login panel.", + }, + }; +} + +export function validateAuthLoginInput(input: AuthLoginInput): string | null { + if (input.proxy_id && input.proxy_name) { + return "proxy_id and proxy_name cannot be used together."; + } + + if (input.mode === "new_login") { + if (!input.domain || !input.profile_name) { + return "domain and profile_name are required for new_login."; + } + if (input.connection_id) { + return "connection_id is not allowed for new_login."; + } + return null; + } + + if (!input.connection_id) { + return "connection_id is required for reauth."; + } + if ( + input.domain || + input.profile_name || + input.save_credentials !== undefined + ) { + return "New-connection configuration is not allowed for reauth."; + } + return null; +} + +function conflictExistingId(error: unknown): string | null { + if (!error || typeof error !== "object") return null; + const candidate = error as { + status?: unknown; + error?: { existing_id?: unknown }; + }; + return candidate.status === 409 && + typeof candidate.error?.existing_id === "string" + ? candidate.error.existing_id + : null; +} + +function loginConflictCode(error: unknown): string | null { + if (!error || typeof error !== "object") return null; + const candidate = error as { + status?: unknown; + error?: { code?: unknown }; + }; + return candidate.status === 409 && typeof candidate.error?.code === "string" + ? candidate.error.code + : null; +} + +function randomResumeId(): string { + return ( + globalThis.crypto?.randomUUID?.() ?? + `auth-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); +} + +function withLoginState( + connection: ManagedAuth, + login: LoginResponse, + preserveServerStep = false, +): ManagedAuth { + return { + ...connection, + flow_status: "IN_PROGRESS", + flow_step: preserveServerStep ? connection.flow_step : null, + flow_type: login.flow_type, + flow_expires_at: login.flow_expires_at, + error_code: null, + error_message: null, + hosted_url: login.hosted_url, + }; +} + +function readyResult( + connection: ManagedAuth, + options: { + startedNewFlow: boolean; + handoffCode?: string; + hostedUrl?: string; + }, +): BeginAuthLoginResult { + return { + state: options.handoffCode + ? "embedded_ready" + : options.hostedUrl + ? "hosted_fallback" + : "observing", + connection: toSafeAuthConnection(connection), + started_new_flow: options.startedNewFlow, + resume_id: randomResumeId(), + ...(options.handoffCode && { handoff_code: options.handoffCode }), + ...(options.hostedUrl && { hosted_url: options.hostedUrl }), + }; +} + +/** + * Start or resume managed authentication. Capability-bearing fields are returned + * only to the caller so the MCP tool can place them in App-private `_meta`. + */ +export async function beginAuthLogin( + client: KernelClient, + input: AuthLoginInput, + now = new Date(), +): Promise { + const validationError = validateAuthLoginInput(input); + if (validationError) throw new Error(validationError); + + let connection: ManagedAuth; + if (input.mode === "new_login") { + try { + connection = await client.auth.connections.create({ + domain: input.domain!, + profile_name: input.profile_name!, + ...(input.save_credentials !== undefined && { + save_credentials: input.save_credentials, + }), + ...((input.proxy_id || input.proxy_name) && { + proxy: { + ...(input.proxy_id && { id: input.proxy_id }), + ...(input.proxy_name && { name: input.proxy_name }), + }, + }), + }); + } catch (error) { + const existingId = conflictExistingId(error); + if (!existingId) throw error; + connection = await client.auth.connections.retrieve(existingId); + } + } else { + connection = await client.auth.connections.retrieve(input.connection_id!); + } + + if (hasLiveAuthFlow(connection, now)) { + // Handoff codes embedded in hosted_url are single-use. An existing flow may + // already have redeemed its code in another panel, so reopening is strictly + // observation-only until the API can mint a fresh resume capability. + return readyResult(connection, { startedNewFlow: false }); + } + + if (input.mode === "new_login" && connection.status === "AUTHENTICATED") { + return { + state: "already_authenticated", + connection: toSafeAuthConnection(connection), + started_new_flow: false, + resume_id: randomResumeId(), + }; + } + + const proxy = + input.proxy_id || input.proxy_name + ? { + ...(input.proxy_id && { id: input.proxy_id }), + ...(input.proxy_name && { name: input.proxy_name }), + } + : undefined; + + try { + const login = await client.auth.connections.login( + connection.id, + proxy ? { proxy } : undefined, + ); + let current = withLoginState(connection, login); + try { + current = await client.auth.connections.retrieve(connection.id); + current = withLoginState(current, login, true); + } catch { + // The login response plus the already-sanitized connection is sufficient. + } + return readyResult(current, { + startedNewFlow: true, + handoffCode: login.handoff_code, + hostedUrl: login.hosted_url, + }); + } catch (error) { + const conflictCode = loginConflictCode(error); + if (conflictCode === "too_many_pending_sessions") { + throw new AuthLoginStartError( + "Too many managed-auth sessions are pending. Close or wait for an existing session to finish, then retry shortly.", + ); + } + if (conflictCode) { + throw new AuthLoginStartError( + `Managed authentication could not start (${conflictCode}). Retry after the current operation finishes.`, + ); + } + throw error; + } +} diff --git a/src/lib/mcp/tools/playwright.ts b/src/lib/mcp/tools/playwright.ts index 9e8f3cc..35232a1 100644 --- a/src/lib/mcp/tools/playwright.ts +++ b/src/lib/mcp/tools/playwright.ts @@ -11,7 +11,7 @@ export function registerPlaywrightTool(server: McpServer) { code: z .string() .describe( - 'Playwright/TypeScript code with `page`, `context`, and `browser` objects in scope; the value you `return` is sent back. Example: `await page.goto(\'https://example.com\'); return await page.title();` Return only what you need — prefer a targeted selector (e.g. `await page.locator(\'h1\').innerText()`) or a region-scoped snapshot (e.g. `await page.locator(\'main\').ariaSnapshot()`) rather than dumping the whole page.', + "Playwright/TypeScript code with `page`, `context`, and `browser` objects in scope; the value you `return` is sent back. Example: `await page.goto('https://example.com'); return await page.title();` Return only what you need — prefer a targeted selector (e.g. `await page.locator('h1').innerText()`) or a region-scoped snapshot (e.g. `await page.locator('main').ariaSnapshot()`) rather than dumping the whole page.", ), session_id: z .string() diff --git a/src/proxy.ts b/src/proxy.ts index 885807e..3daf5a9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,10 @@ const isPublicRoute = createRouteMatcher([ "/register", "/authorize", "/token", + // Public only at this narrow relay boundary. The route itself accepts an + // unauthenticated single-use exchange and validates scoped managed-auth JWTs + // for retrieve, submit, and events. + "/managed-auth-proxy/auth/connections/(.*)", ]); export default clerkMiddleware(async (auth, req) => { From d9f78be25076cbf166d1e4ecdd8d55c03b7344a2 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 28 Jul 2026 06:21:45 -0400 Subject: [PATCH 02/12] feat: automatically resume agent after managed auth --- docs/mcp-apps-prototype.md | 2 +- scripts/mcp-apps-proxy.ts | 2 +- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 67 ++++++++++++------- src/lib/mcp/tools/auth-login-app.test.ts | 4 +- src/lib/mcp/tools/auth-login-app.ts | 2 +- 6 files changed, 51 insertions(+), 28 deletions(-) diff --git a/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md index 2ff2b65..030b0a4 100644 --- a/docs/mcp-apps-prototype.md +++ b/docs/mcp-apps-prototype.md @@ -17,7 +17,7 @@ manage_auth_connections(action="list", domain_filter=...) ``` `open_auth_login` does not create a connection or flow until the user clicks Continue. The -single-file `ui://kernel/managed-auth-login-v4.html` resource bundles +single-file `ui://kernel/managed-auth-login-v5.html` resource bundles `@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff code and hosted fallback URL exist only in the app-only `begin_auth_login` result `_meta`. diff --git a/scripts/mcp-apps-proxy.ts b/scripts/mcp-apps-proxy.ts index df170f2..39ea443 100644 --- a/scripts/mcp-apps-proxy.ts +++ b/scripts/mcp-apps-proxy.ts @@ -306,7 +306,7 @@ const AUTH_QA_HARNESS_HTML = /* html */ ` text_only: false, }; launcher = await callTool("open_auth_login", input); - const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v4.html" }); + const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v5.html" }); frame.srcdoc = resource.contents[0].text; log("Secure App resource rendered."); } catch (error) { log("ERROR: " + error.message); } diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index 3bbe696..381108f 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +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
Preparing secure login…
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index 6785582..214ef20 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -244,6 +244,9 @@ function ManagedAuthApp() { const [beginResult, setBeginResult] = useState(null); const [starting, setStarting] = useState(false); const [terminal, setTerminal] = useState<"success" | "failure" | null>(null); + const [agentNotification, setAgentNotification] = useState< + "idle" | "sending" | "sent" | "failed" + >("idle"); const [statusText, setStatusText] = useState(""); const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); const [embeddedRetryRequired, setEmbeddedRetryRequired] = useState(false); @@ -395,7 +398,32 @@ function ManagedAuthApp() { structuredContent: context.structuredContent, }); } catch { - // The visible Continue agent action remains available as a fallback. + // ui/message below still carries the sanitized terminal status. + } + } + + async function notifyAgent(outcome: "success" | "failure") { + if (!resumeId || destroyed) { + setAgentNotification("failed"); + return; + } + setAgentNotification("sending"); + await publishTerminal(outcome); + const messageKey = `kernel-managed-auth-message:${resumeId}:${outcome}`; + if (!claimOneShot(messageKey)) { + setAgentNotification("sent"); + return; + } + const context = terminalContext(outcome); + try { + await sendRequest("ui/message", { + role: "user", + content: [{ type: "text", text: context.text }], + }); + setAgentNotification("sent"); + } catch { + releaseOneShot(messageKey); + setAgentNotification("failed"); } } @@ -404,7 +432,7 @@ function ManagedAuthApp() { if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); setTerminal(outcome); setStatusText(""); - void publishTerminal(outcome); + void notifyAgent(outcome); } async function checkStatus() { @@ -499,23 +527,6 @@ function ManagedAuthApp() { } }); - async function continueAgent() { - if (!resumeId || !terminal) return; - const messageKey = `kernel-managed-auth-message:${resumeId}:${terminal}`; - if (!claimOneShot(messageKey)) return; - const context = terminalContext(terminal); - try { - await sendRequest("ui/message", { - role: "user", - content: [{ type: "text", text: context.text }], - }); - setStatusText("Agent notified."); - } catch { - releaseOneShot(messageKey); - setStatusText("Tell the agent to verify the connection and continue."); - } - } - if (!launcher.input || !launcher.result) { return
Preparing secure login…
; } @@ -551,10 +562,20 @@ function ManagedAuthApp() { /> )}
- - {statusText &&

{statusText}

} + {agentNotification === "failed" ? ( + + ) : ( +

+ {agentNotification === "sent" + ? "Agent notified. You can close this panel." + : "Notifying agent…"} +

+ )}
diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index afb5d87..75b12c9 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -155,8 +155,10 @@ describe("managed-auth MCP App registration", () => { ); }); - test("terminal model context excludes internal identifiers", () => { + test("terminal model context excludes internal identifiers and auto-notifies the agent", () => { expect(MANAGED_AUTH_APP_HTML).toContain("profile_name"); + expect(MANAGED_AUTH_APP_HTML).toContain("Agent notified"); + expect(MANAGED_AUTH_APP_HTML).not.toContain("Continue agent"); expect(MANAGED_AUTH_APP_HTML).not.toContain("connectionId"); expect(MANAGED_AUTH_APP_HTML).not.toContain("claimedOutcome"); expect(MANAGED_AUTH_APP_HTML).not.toContain("resumeId"); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 38ced89..5f20c17 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -13,7 +13,7 @@ import { import { errorResponse } from "@/lib/mcp/responses"; export const MANAGED_AUTH_RESOURCE_URI = - "ui://kernel/managed-auth-login-v4.html"; + "ui://kernel/managed-auth-login-v5.html"; export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; export function managedAuthAppOrigin(): string { From 8602a929f635bba2cd0bbc921ddb377d9130520e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 28 Jul 2026 06:49:39 -0400 Subject: [PATCH 03/12] feat: add managed auth panel close control --- docs/mcp-apps-prototype.md | 2 +- scripts/mcp-apps-proxy.ts | 2 +- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 29 +++++++++++++++---- src/lib/mcp/tools/auth-login-app.test.ts | 2 ++ src/lib/mcp/tools/auth-login-app.ts | 2 +- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md index 030b0a4..0ac02e9 100644 --- a/docs/mcp-apps-prototype.md +++ b/docs/mcp-apps-prototype.md @@ -17,7 +17,7 @@ manage_auth_connections(action="list", domain_filter=...) ``` `open_auth_login` does not create a connection or flow until the user clicks Continue. The -single-file `ui://kernel/managed-auth-login-v5.html` resource bundles +single-file `ui://kernel/managed-auth-login-v6.html` resource bundles `@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff code and hosted fallback URL exist only in the app-only `begin_auth_login` result `_meta`. diff --git a/scripts/mcp-apps-proxy.ts b/scripts/mcp-apps-proxy.ts index 39ea443..d47375f 100644 --- a/scripts/mcp-apps-proxy.ts +++ b/scripts/mcp-apps-proxy.ts @@ -306,7 +306,7 @@ const AUTH_QA_HARNESS_HTML = /* html */ ` text_only: false, }; launcher = await callTool("open_auth_login", input); - const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v5.html" }); + const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v6.html" }); frame.srcdoc = resource.contents[0].text; log("Secure App resource rendered."); } catch (error) { log("ERROR: " + error.message); } diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index 381108f..c9ad8d6 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +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
Preparing secure login…
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index 214ef20..b8faffb 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -65,6 +65,7 @@ type BeginResult = { let nextRequestId = 1; const pendingRequests = new Map(); let destroyed = false; +let collapsed = false; let reactRoot: Root | null = null; let completeToolInput: JsonObject | null = null; let launcherToolResult: JsonObject | null = null; @@ -134,7 +135,9 @@ function applyHostContext(context: JsonObject | undefined) { function reportSize() { sendNotification("ui/notifications/size-changed", { - height: Math.max(document.documentElement.scrollHeight, 360), + height: collapsed + ? 1 + : Math.max(document.documentElement.scrollHeight, 360), }); } @@ -247,6 +250,7 @@ function ManagedAuthApp() { const [agentNotification, setAgentNotification] = useState< "idle" | "sending" | "sent" | "failed" >("idle"); + const [dismissed, setDismissed] = useState(false); const [statusText, setStatusText] = useState(""); const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); const [embeddedRetryRequired, setEmbeddedRetryRequired] = useState(false); @@ -527,6 +531,16 @@ function ManagedAuthApp() { } }); + function closePanel() { + collapsed = true; + setDismissed(true); + window.setTimeout(reportSize, 0); + } + + if (dismissed) { + return diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 75b12c9..7480296 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -158,6 +158,8 @@ describe("managed-auth MCP App registration", () => { test("terminal model context excludes internal identifiers and auto-notifies the agent", () => { expect(MANAGED_AUTH_APP_HTML).toContain("profile_name"); expect(MANAGED_AUTH_APP_HTML).toContain("Agent notified"); + expect(MANAGED_AUTH_APP_HTML).toContain("Close panel"); + expect(MANAGED_AUTH_APP_HTML).not.toContain("You can close this panel"); expect(MANAGED_AUTH_APP_HTML).not.toContain("Continue agent"); expect(MANAGED_AUTH_APP_HTML).not.toContain("connectionId"); expect(MANAGED_AUTH_APP_HTML).not.toContain("claimedOutcome"); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 5f20c17..a59f139 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -13,7 +13,7 @@ import { import { errorResponse } from "@/lib/mcp/responses"; export const MANAGED_AUTH_RESOURCE_URI = - "ui://kernel/managed-auth-login-v5.html"; + "ui://kernel/managed-auth-login-v6.html"; export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; export function managedAuthAppOrigin(): string { From 116200a45faa9700a554c42b92731356fe5827b3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 28 Jul 2026 17:49:02 -0400 Subject: [PATCH 04/12] feat: await managed auth without prompt injection --- README.md | 5 +- docs/mcp-apps-prototype.md | 11 +- scripts/mcp-apps-proxy.ts | 6 +- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 66 ++-------- src/lib/mcp/tools/auth-connections.test.ts | 88 +++++++++++++ src/lib/mcp/tools/auth-connections.ts | 72 ++++++++++- src/lib/mcp/tools/auth-login-app.test.ts | 18 ++- src/lib/mcp/tools/auth-login-app.ts | 40 ++++-- src/lib/mcp/tools/managed-auth-state.ts | 122 ++++++++++++++++++ 10 files changed, 345 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 91ba5e8..2511cf8 100644 --- a/README.md +++ b/README.md @@ -333,8 +333,9 @@ Returns: { success: true, result: "Example Domain" } 1. Call `manage_auth_connections` with `action: "list"` and `domain_filter`. 2. Fetch all pages. If multiple profiles match, ask the user which profile to use. 3. If the selected connection needs auth, explain why and wait for consent before calling `open_auth_login`. -4. The user enters credentials/MFA only in the secure MCP App. Never ask for them in chat. -5. Re-fetch the connection and create the browser with its `profile_name` only after it reports `AUTHENTICATED`. +4. Immediately follow the launcher's `next_action`: long-poll `manage_auth_connections` with `action: "wait"`, repeating while it reports `pending`. +5. The user enters credentials/MFA only in the secure MCP App. Never ask for them in chat. +6. When the wait returns `authenticated`, continue the pending task and create the browser with the verified `profile_name`. If no App appears, ask the user to confirm that before retrying `open_auth_login` with `text_only: true`. That compatibility path returns a capability-bearing hosted login URL as diff --git a/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md index 0ac02e9..7aaa3ee 100644 --- a/docs/mcp-apps-prototype.md +++ b/docs/mcp-apps-prototype.md @@ -8,16 +8,17 @@ The managed-auth flow is discovery-first and fail-closed: manage_auth_connections(action="list", domain_filter=...) -> ask the user to choose/consent -> open_auth_login(...) + -> agent immediately starts manage_auth_connections(action="wait") -> user clicks Continue in the App -> begin_auth_login (app-only) -> bundled KernelManagedAuth uses the scoped relay - -> get_auth_login_status (app-only) - -> Continue agent - -> agent re-fetches and proceeds only if AUTHENTICATED + -> App publishes verified status with ui/update-model-context + -> read-only wait returns authenticated + -> agent continues the pending task automatically ``` `open_auth_login` does not create a connection or flow until the user clicks Continue. The -single-file `ui://kernel/managed-auth-login-v6.html` resource bundles +single-file `ui://kernel/managed-auth-login-v7.html` resource bundles `@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff code and hosted fallback URL exist only in the app-only `begin_auth_login` result `_meta`. @@ -27,7 +28,7 @@ exchange, retrieve, submit, and events paths and forwards only managed-auth scop For clients without Apps, first confirm that no panel appeared, then call `open_auth_login` with `text_only: true`. This explicit compatibility exception places the full hosted URL (including its embedded handoff capability) in user-audience text. It never emits a separate -`handoff_code`, and the agent must re-fetch the connection instead of treating URL creation +`handoff_code`, and the agent must run the same read-only wait instead of treating URL creation as successful authentication. Local QA: diff --git a/scripts/mcp-apps-proxy.ts b/scripts/mcp-apps-proxy.ts index d47375f..7c8e3f0 100644 --- a/scripts/mcp-apps-proxy.ts +++ b/scripts/mcp-apps-proxy.ts @@ -306,7 +306,7 @@ const AUTH_QA_HARNESS_HTML = /* html */ ` text_only: false, }; launcher = await callTool("open_auth_login", input); - const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v6.html" }); + const resource = await rpc("resources/read", { uri: "ui://kernel/managed-auth-login-v7.html" }); frame.srcdoc = resource.contents[0].text; log("Secure App resource rendered."); } catch (error) { log("ERROR: " + error.message); } @@ -390,7 +390,9 @@ Bun.serve({ if (m.method === "resources/read") detail += ` uri=${m.params?.uri}`; if (m.method === "initialize") { const client = m.params?.clientInfo; - detail += ` client=${client?.name}@${client?.version} extensions=${JSON.stringify( + detail += ` client=${client?.name}@${client?.version} protocol=${m.params?.protocolVersion ?? "unknown"} tasks=${JSON.stringify( + m.params?.capabilities?.tasks ?? null, + )} extensions=${JSON.stringify( m.params?.capabilities?.extensions ?? null, )}`; } diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index c9ad8d6..2f65d27 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +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
Preparing secure login…
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index b8faffb..faa1e61 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -86,15 +86,6 @@ function claimOneShot(key: string): boolean { return true; } -function releaseOneShot(key: string) { - oneShotKeys.delete(key); - try { - sessionStorage.removeItem(key); - } catch { - // See claimOneShot. - } -} - function notifyStateChanged() { stateVersion += 1; for (const listener of listeners) listener(); @@ -247,9 +238,6 @@ function ManagedAuthApp() { const [beginResult, setBeginResult] = useState(null); const [starting, setStarting] = useState(false); const [terminal, setTerminal] = useState<"success" | "failure" | null>(null); - const [agentNotification, setAgentNotification] = useState< - "idle" | "sending" | "sent" | "failed" - >("idle"); const [dismissed, setDismissed] = useState(false); const [statusText, setStatusText] = useState(""); const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); @@ -402,32 +390,7 @@ function ManagedAuthApp() { structuredContent: context.structuredContent, }); } catch { - // ui/message below still carries the sanitized terminal status. - } - } - - async function notifyAgent(outcome: "success" | "failure") { - if (!resumeId || destroyed) { - setAgentNotification("failed"); - return; - } - setAgentNotification("sending"); - await publishTerminal(outcome); - const messageKey = `kernel-managed-auth-message:${resumeId}:${outcome}`; - if (!claimOneShot(messageKey)) { - setAgentNotification("sent"); - return; - } - const context = terminalContext(outcome); - try { - await sendRequest("ui/message", { - role: "user", - content: [{ type: "text", text: context.text }], - }); - setAgentNotification("sent"); - } catch { - releaseOneShot(messageKey); - setAgentNotification("failed"); + // The verified terminal state remains visible in the App. } } @@ -436,7 +399,7 @@ function ManagedAuthApp() { if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); setTerminal(outcome); setStatusText(""); - void notifyAgent(outcome); + void publishTerminal(outcome); } async function checkStatus() { @@ -576,23 +539,14 @@ function ManagedAuthApp() { /> )}
- {agentNotification === "failed" ? ( - - ) : agentNotification === "sent" ? ( - <> -

Agent notified.

- - - ) : ( -

Notifying agent…

- )} +

+ {terminal === "success" + ? "Connection status saved for Claude’s next turn." + : "Failure status saved for Claude’s next turn."} +

+
diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index 15ed96b..fcab85a 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -6,6 +6,7 @@ import { beginAuthLogin, deriveAuthNextAction, toSafeAuthConnection, + waitForAuthConnection, } from "@/lib/mcp/tools/managed-auth-state"; import type { ManagedAuth } from "@onkernel/sdk/resources/auth/connections"; @@ -121,6 +122,7 @@ describe("managed-auth safe responses", () => { registerAuthConnectionTools(server); expect(schema?.action.safeParse("list").success).toBe(true); expect(schema?.action.safeParse("get").success).toBe(true); + expect(schema?.action.safeParse("wait").success).toBe(true); expect(schema?.action.safeParse("create").success).toBe(false); expect(schema?.action.safeParse("delete").success).toBe(false); expect(schema?.action.safeParse("login").success).toBe(false); @@ -222,6 +224,92 @@ describe("managed-auth safe responses", () => { }); }); +describe("managed-auth wait", () => { + test("long-polls until an exact connection is authenticated", async () => { + const states = [ + connection({ flow_status: "IN_PROGRESS" }), + connection({ status: "AUTHENTICATED", flow_status: "SUCCESS" }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + + const result = await waitForAuthConnection( + client, + { connectionId: "conn_1" }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("authenticated"); + expect(calls).toBe(2); + assertNoSecrets(result); + }); + + test("does not accept stale authenticated state while re-auth is pending", async () => { + const stale = connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "REAUTH", + flow_expires_at: "2026-01-01T00:00:00Z", + }); + const client = { + auth: { connections: { retrieve: async () => stale } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: stale.id, + requiredFlowType: "REAUTH", + previousFlowExpiresAt: stale.flow_expires_at, + }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + + test("returns safe failure and pending states", async () => { + const failedClient = { + auth: { + connections: { + retrieve: async () => + connection({ + flow_status: "FAILED", + error_message: "raw site/API failure with secret", + }), + }, + }, + } as unknown as KernelClient; + const failed = await waitForAuthConnection( + failedClient, + { connectionId: "conn_1" }, + { timeoutMs: 0 }, + ); + expect(failed.state).toBe("failed"); + assertNoSecrets(failed); + + const pendingClient = { + auth: { + connections: { + list: async () => ({ + getPaginatedItems: () => [], + hasNextPage: () => false, + }), + }, + }, + } as unknown as KernelClient; + const pending = await waitForAuthConnection( + pendingClient, + { domain: "example.com", profileName: "work" }, + { timeoutMs: 0 }, + ); + expect(pending).toEqual({ state: "pending" }); + }); +}); + describe("managed-auth start/resume state machine", () => { test("does not restart a live unexpired flow", async () => { const initial = connection({ diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index e4a6ed3..6b62de0 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -2,8 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; import { + AuthLoginStartError, deriveAuthNextAction, toSafeAuthConnection, + waitForAuthConnection, } from "@/lib/mcp/tools/managed-auth-state"; import { errorResponse } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -23,21 +25,39 @@ function safeJsonResponse(value: unknown) { export function registerAuthConnectionTools(server: McpServer) { server.tool( "manage_auth_connections", - 'Discover sanitized Kernel managed-auth connections. Start every protected-site task with action="list" and domain_filter. Reason over all pages: use an AUTHENTICATED profile with manage_browsers, ask the user to choose when multiple profiles match, or ask for consent before calling open_auth_login for a new login/re-auth. Never ask the user to send passwords, OTPs, or MFA values in conversation. Connection creation, deletion, login, and credential mutation are intentionally outside this model-facing discovery tool. Actions: list, get.', + 'Discover and wait for sanitized Kernel managed-auth connections. Start every protected-site task with action="list" and domain_filter. Reason over all pages: use an AUTHENTICATED profile with manage_browsers, ask the user to choose when multiple profiles match, or ask for consent before calling open_auth_login for a new login/re-auth. After open_auth_login, immediately follow its next_action by calling action="wait"; repeat while state is pending so the current agent turn resumes automatically when authentication completes. Never ask the user to send passwords, OTPs, or MFA values in conversation. Connection creation, deletion, login, and credential mutation are intentionally outside this model-facing tool. Actions: list, get, wait.', { - action: z.enum(["list", "get"]).describe("Discovery operation."), + action: z.enum(["list", "get", "wait"]).describe("Read operation."), id: z .string() - .describe("Auth connection ID. Required for get.") + .describe("Auth connection ID. Required for get and re-auth wait.") .optional(), profile_name: z .string() - .describe("(list) Filter by profile_name.") + .describe("Exact profile_name filter for list or new-login wait.") .optional(), domain_filter: z .string() .describe( - "(list) Domain to match. Always set this when discovering a profile for a protected site.", + "Domain to match for list or new-login wait. Always set this when discovering a profile for a protected site.", + ) + .optional(), + wait_seconds: z + .number() + .int() + .min(1) + .max(30) + .describe("(wait) Long-poll duration. Defaults to 25 seconds.") + .optional(), + required_flow_type: z + .enum(["LOGIN", "REAUTH"]) + .describe("(wait) Require this newly completed flow type.") + .optional(), + previous_flow_expires_at: z + .string() + .nullable() + .describe( + "(wait) Baseline flow expiry supplied by open_auth_login; do not modify.", ) .optional(), ...paginationParams, @@ -95,10 +115,48 @@ export function registerAuthConnectionTools(server: McpServer) { : "Do not continue the protected action. Ask for consent, then use open_auth_login to authenticate securely.", }); } + case "wait": { + if (!params.id && (!params.domain_filter || !params.profile_name)) { + return errorResponse( + "Error: wait requires id, or both domain_filter and profile_name.", + ); + } + const result = await waitForAuthConnection( + client, + { + ...(params.id && { connectionId: params.id }), + ...(params.domain_filter && { domain: params.domain_filter }), + ...(params.profile_name && { + profileName: params.profile_name, + }), + ...(params.required_flow_type && { + requiredFlowType: params.required_flow_type, + }), + ...(params.previous_flow_expires_at !== undefined && { + previousFlowExpiresAt: params.previous_flow_expires_at, + }), + }, + { + timeoutMs: (params.wait_seconds ?? 25) * 1_000, + signal: extra.signal, + }, + ); + return safeJsonResponse({ + ...result, + instruction: + result.state === "authenticated" + ? "Authentication is verified. Continue the pending task now, using this profile_name when creating the browser." + : result.state === "failed" + ? "Authentication did not complete. Explain the safe error and ask whether to retry open_auth_login." + : "Authentication is still pending. Immediately call manage_auth_connections with action=wait and the same selector again. Do not ask the user to report completion.", + }); + } } - } catch { + } catch (error) { return errorResponse( - `Managed-auth ${params.action} failed. Retry or choose a different recovery option.`, + error instanceof AuthLoginStartError + ? error.safeMessage + : `Managed-auth ${params.action} failed. Retry or choose a different recovery option.`, ); } }, diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 7480296..a7df0ba 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -122,6 +122,15 @@ describe("managed-auth MCP App registration", () => { mode: "new_login", connection: { domain: "example.com", profile_name: "work" }, text_only: false, + next_action: { + tool: "manage_auth_connections", + arguments: { + action: "wait", + domain_filter: "example.com", + profile_name: "work", + wait_seconds: 25, + }, + }, app_capability: expect.any(String), }); expect(JSON.stringify(result)).not.toContain("?code="); @@ -155,11 +164,14 @@ describe("managed-auth MCP App registration", () => { ); }); - test("terminal model context excludes internal identifiers and auto-notifies the agent", () => { + test("terminal model context excludes internal identifiers and never writes the prompt", () => { expect(MANAGED_AUTH_APP_HTML).toContain("profile_name"); - expect(MANAGED_AUTH_APP_HTML).toContain("Agent notified"); + expect(MANAGED_AUTH_APP_HTML).toContain( + "Connection status saved for Claude", + ); expect(MANAGED_AUTH_APP_HTML).toContain("Close panel"); - expect(MANAGED_AUTH_APP_HTML).not.toContain("You can close this panel"); + expect(MANAGED_AUTH_APP_HTML).not.toContain('sendRequest("ui/message"'); + expect(MANAGED_AUTH_APP_HTML).not.toContain("Agent notified"); expect(MANAGED_AUTH_APP_HTML).not.toContain("Continue agent"); expect(MANAGED_AUTH_APP_HTML).not.toContain("connectionId"); expect(MANAGED_AUTH_APP_HTML).not.toContain("claimedOutcome"); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index a59f139..0c38917 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -13,7 +13,7 @@ import { import { errorResponse } from "@/lib/mcp/responses"; export const MANAGED_AUTH_RESOURCE_URI = - "ui://kernel/managed-auth-login-v6.html"; + "ui://kernel/managed-auth-login-v7.html"; export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; export function managedAuthAppOrigin(): string { @@ -143,7 +143,7 @@ export function registerAuthLoginApp(server: McpServer) { { title: "Open secure managed-auth login", description: - 'Display a secure Kernel login panel after the user consents. Before calling: use manage_auth_connections(action="list", domain_filter=...) across all pages, reason about the result, and ask the user which profile to use if needed. Never ask for passwords, credentials, OTPs, or MFA values in conversation. The user enters them only in the panel. After completion, re-fetch the connection and continue only when status is AUTHENTICATED. This call does not create or start a flow until the user clicks Continue. Use text_only=true only after the user confirms no App appeared; that compatibility fallback exposes a capability-bearing hosted URL as user-audience text.', + 'Display a secure Kernel login panel after the user consents. Before calling: use manage_auth_connections(action="list", domain_filter=...) across all pages, reason about the result, and ask the user which profile to use if needed. Never ask for passwords, credentials, OTPs, or MFA values in conversation. The user enters them only in the panel. Immediately follow this tool result\'s next_action and repeat its read-only wait while pending; continue only when it returns authenticated. This call does not create or start a flow until the user clicks Continue. Use text_only=true only after the user confirms no App appeared; that compatibility fallback exposes a capability-bearing hosted URL as user-audience text.', inputSchema: { ...authLoginInputSchema, text_only: z.boolean().default(false), @@ -179,7 +179,7 @@ export function registerAuthLoginApp(server: McpServer) { }> = [ { type: "text", - text: `Secure managed authentication is ${result.state === "already_authenticated" ? "already complete" : "ready"} for connection ${result.connection.id}. Expiry: ${result.connection.flow_expires_at ?? "not applicable"}. Do not claim success from this response. After the user completes login, re-fetch with manage_auth_connections and continue only if status is AUTHENTICATED.`, + text: `Secure managed authentication is ${result.state === "already_authenticated" ? "already complete" : "ready"} for connection ${result.connection.id}. Expiry: ${result.connection.flow_expires_at ?? "not applicable"}. Do not claim success from this response. Immediately call manage_auth_connections with action=wait, id=${result.connection.id}, and wait_seconds=25; repeat while pending and continue only when it returns authenticated.`, }, ]; if (result.hosted_url) { @@ -201,21 +201,39 @@ export function registerAuthLoginApp(server: McpServer) { }; } - const connection = + const reauthConnection = input.mode === "reauth" ? toSafeAuthConnection( await client.auth.connections.retrieve(input.connection_id!), ) - : { - domain: input.domain!, - profile_name: input.profile_name!, - }; + : null; + const connection = reauthConnection ?? { + domain: input.domain!, + profile_name: input.profile_name!, + }; + const waitArguments = reauthConnection + ? { + action: "wait", + id: input.connection_id!, + wait_seconds: 25, + required_flow_type: "REAUTH", + ...(reauthConnection.status === "AUTHENTICATED" && + reauthConnection.flow_status !== "IN_PROGRESS" && { + previous_flow_expires_at: reauthConnection.flow_expires_at, + }), + } + : { + action: "wait", + domain_filter: input.domain!, + profile_name: input.profile_name!, + wait_seconds: 25, + }; const appCapability = issueAppCapability(extra.authInfo.token); return { content: [ { type: "text" as const, - text: "A secure Kernel login panel was requested. Do not claim that it rendered or that authentication succeeded. Never ask for credentials in conversation. Wait for the App to report completion, then re-fetch and verify the connection before continuing.", + text: `A secure Kernel login panel was requested. Do not claim that it rendered or that authentication succeeded. Never ask for credentials in conversation. Immediately call manage_auth_connections with ${JSON.stringify(waitArguments)}. While it returns state=pending, call it again with the same arguments instead of asking the user to report completion. Continue the pending task only after it returns state=authenticated.`, }, ], structuredContent: { @@ -224,6 +242,10 @@ export function registerAuthLoginApp(server: McpServer) { mode: input.mode, connection, text_only: false, + next_action: { + tool: "manage_auth_connections", + arguments: waitArguments, + }, // MCP Apps structuredContent is delivered to the View but is not // added to model context. Claude currently strips tool-result // _meta from launcher notifications, so duplicate this short-lived, diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index f20a324..fd6a60c 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -84,6 +84,19 @@ export interface BeginAuthLoginResult { hosted_url?: string; } +export interface AuthWaitSelector { + connectionId?: string; + domain?: string; + profileName?: string; + requiredFlowType?: "LOGIN" | "REAUTH"; + previousFlowExpiresAt?: string | null; +} + +export interface AuthWaitResult { + state: "authenticated" | "failed" | "pending"; + connection?: SafeAuthConnection; +} + const TERMINAL_ERROR_MESSAGES: Partial< Record, string> > = { @@ -123,6 +136,115 @@ export function hasLiveAuthFlow( return Number.isFinite(expiresAt) && expiresAt > now.getTime(); } +async function findAuthConnection( + client: KernelClient, + selector: AuthWaitSelector, +): Promise { + if (selector.connectionId) { + return await client.auth.connections.retrieve(selector.connectionId); + } + if (!selector.domain || !selector.profileName) { + throw new AuthLoginStartError( + "Waiting for managed authentication requires a connection ID or an exact domain and profile name.", + ); + } + + const page = await client.auth.connections.list({ + domain: selector.domain, + profile_name: selector.profileName, + limit: 100, + }); + const matches = page + .getPaginatedItems() + .filter( + (item) => + item.domain === selector.domain && + item.profile_name === selector.profileName, + ); + if (matches.length > 1 || (matches.length > 0 && page.hasNextPage())) { + throw new AuthLoginStartError( + "Multiple managed-auth connections matched while waiting. Select a connection explicitly.", + ); + } + return matches[0] ?? null; +} + +function authWaitDelay(milliseconds: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Managed-auth wait was cancelled.")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new Error("Managed-auth wait was cancelled.")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export async function waitForAuthConnection( + client: KernelClient, + selector: AuthWaitSelector, + options: { + timeoutMs?: number; + pollIntervalMs?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const timeoutMs = Math.max(0, options.timeoutMs ?? 25_000); + const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 1_000); + const deadline = Date.now() + timeoutMs; + let observedQuery = false; + let latest: SafeAuthConnection | undefined; + + do { + if (options.signal?.aborted) { + throw new Error("Managed-auth wait was cancelled."); + } + try { + const connection = await findAuthConnection(client, selector); + observedQuery = true; + if (connection) { + latest = toSafeAuthConnection(connection); + const requiredFlowCompleted = selector.requiredFlowType + ? latest.flow_status === "SUCCESS" && + latest.flow_type === selector.requiredFlowType && + (selector.previousFlowExpiresAt === undefined || + latest.flow_expires_at !== selector.previousFlowExpiresAt) + : true; + if (latest.status === "AUTHENTICATED" && requiredFlowCompleted) { + return { state: "authenticated", connection: latest }; + } + if ( + latest.flow_status === "FAILED" || + latest.flow_status === "EXPIRED" || + latest.flow_status === "CANCELED" + ) { + return { state: "failed", connection: latest }; + } + } + } catch (error) { + if (error instanceof AuthLoginStartError) throw error; + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await authWaitDelay(Math.min(pollIntervalMs, remaining), options.signal); + } while (Date.now() <= deadline); + + if (!observedQuery) { + throw new AuthLoginStartError( + "Managed authentication status could not be checked. Retry the wait operation.", + ); + } + return { state: "pending", ...(latest && { connection: latest }) }; +} + export function deriveAuthNextAction({ items, hasMore, From 47e0830de049a9fd1b8c3ef7aeb60ac950acba25 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 09:22:15 -0400 Subject: [PATCH 05/12] fix: fail closed on non-Apps hosts, Bun-pinned CI, reauth wait guards Review follow-ups for the managed-auth MCP App: - CI: pin Bun 1.3.3 in setup-bun; the byte-exact bundle check is minifier-version-dependent and broke on latest-Bun runners. - Fail closed on hosts without MCP Apps support: begin_auth_login, get_auth_login_status, and delete_auth_login_connection now execute only when the client declared io.modelcontextprotocol/ui. The streamable-HTTP transport is stateless, so the route layer records the declared capability per bearer token in Redis at initialize; SSE transports are checked via the SDK server directly. Verified end to end against a live server. - app_capability (delete authorization) is no longer duplicated into model-visible structuredContent; it is issued only to Apps-capable clients and travels only in launcher _meta. - waitForAuthConnection: AUTHENTICATED with a live IN_PROGRESS flow is now pending (no stale re-auth acceptance), and flow-guarded waits accept any successful new flow instead of a pre-guessed type, with an observed-live-flow backstop and stale-terminal-flow suppression. beginAuthLogin returns the pre-flow baseline; the text_only fallback and the App launcher both emit baseline-guarded wait arguments, and the launcher no longer hardcodes required_flow_type=REAUTH. - Managed-auth App pins postMessage replies to the host origin learned from the first validated parent message ("*" only pre-handshake, nosemgrep with justification); live-view App adds the missing event.source guard. Bundle regenerated. - z.string().min(1) for connection ids; .env.example lists live_view_app; README tool inventory updated; docs aligned with actual behavior. Regression tests cover the non-Apps fail-closed gate, the Redis-marker path, initialize detection, baseline-guarded waits (LOGIN-typed reauth, stale success, observed-flow backstop, stale terminal flow), live-flow pending, launcher wait arguments, and text_only handoff hygiene. --- .env.example | 2 +- .github/workflows/ci.yml | 5 + README.md | 5 +- docs/mcp-apps-prototype.md | 23 +- scripts/build-managed-auth-app.mjs | 5 + src/app/[transport]/route.ts | 35 ++ .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 34 +- src/lib/mcp/tools/auth-connections.test.ts | 163 +++++++++ src/lib/mcp/tools/auth-connections.ts | 1 + src/lib/mcp/tools/auth-login-app.test.ts | 312 +++++++++++++++++- src/lib/mcp/tools/auth-login-app.ts | 139 ++++++-- src/lib/mcp/tools/live-view-app.ts | 2 + src/lib/mcp/tools/managed-auth-state.ts | 60 +++- src/lib/redis.ts | 51 +++ 15 files changed, 781 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index 7c7af38..1f691bc 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ 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, auth_connections, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell +# Supported: apps, api_keys, auth_connections, browser_pools, browsers, computer, docs, extensions, live_view_app, 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 280c2e1..acfcb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,12 @@ 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 diff --git a/README.md b/README.md index 2511cf8..48b43c2 100644 --- a/README.md +++ b/README.md @@ -255,10 +255,12 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (16 total) +## Tools (19 model-facing, plus 4 app-only helpers) 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. +Four additional helper tools (`begin_auth_login`, `get_auth_login_status`, `delete_auth_login_connection`, `capture_live_view_frame`) are marked app-only (`_meta.ui.visibility: ["app"]`) and exist solely for the embedded MCP Apps views; they refuse to execute on hosts that do not declare MCP Apps support. + 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. ### manage\_\* tools @@ -284,6 +286,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `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. Credentials and MFA never enter MCP/model traffic. +- `show_browser_live_view` - Render an embedded, read-only live view of a browser session inline in the chat (auto-refreshing snapshots) on MCP Apps-capable hosts; returns the live view URLs as text elsewhere. ## Resources diff --git a/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md index 7aaa3ee..9a35eb5 100644 --- a/docs/mcp-apps-prototype.md +++ b/docs/mcp-apps-prototype.md @@ -21,15 +21,34 @@ manage_auth_connections(action="list", domain_filter=...) single-file `ui://kernel/managed-auth-login-v7.html` resource bundles `@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff -code and hosted fallback URL exist only in the app-only `begin_auth_login` result `_meta`. +code and hosted fallback URL exist only in the app-only `begin_auth_login` result (`_meta`, +duplicated into `structuredContent.app_private` because some hosts strip tool-result `_meta`). +App-only tools fail closed: `begin_auth_login`, `get_auth_login_status`, and +`delete_auth_login_connection` execute only when the connected client declared the +`io.modelcontextprotocol/ui` extension during initialize, so on hosts without MCP Apps +support the model cannot call them to bypass user consent or pull App-private material into +its context. The delete-authorizing `app_capability` is likewise issued only to +MCP Apps-capable clients and travels only in the launcher result `_meta`. +Because the streamable-HTTP transport is stateless (one `McpServer` per request), the route +layer (`src/app/[transport]/route.ts`) observes each `initialize` body and records the +declared capability per bearer token in Redis (sliding TTL, bound to the token lifetime for +OAuth tokens); persistent SSE transports are checked directly through the SDK server. The component sends credential input directly to `/managed-auth-proxy`, which accepts only exchange, retrieve, submit, and events paths and forwards only managed-auth scoped JWTs. +The bundled App resource is byte-checked in CI (`bun run check:managed-auth-app`) and Bun's +minifier output can change between releases, so CI pins Bun (see `.github/workflows/ci.yml`). +Regenerate the bundle with that exact Bun version after editing +`src/lib/mcp/apps/managed-auth-entry.tsx`: `bun run build:managed-auth-app`. + For clients without Apps, first confirm that no panel appeared, then call `open_auth_login` with `text_only: true`. This explicit compatibility exception places the full hosted URL (including its embedded handoff capability) in user-audience text. It never emits a separate `handoff_code`, and the agent must run the same read-only wait instead of treating URL creation -as successful authentication. +as successful authentication. The wait is guarded by the pre-flow `previous_flow_expires_at` +baseline captured at begin time, and a connection that is `AUTHENTICATED` while a flow is +still `IN_PROGRESS` reads as pending — stale pre-flow state is never accepted as the result +of a re-auth. Local QA: diff --git a/scripts/build-managed-auth-app.mjs b/scripts/build-managed-auth-app.mjs index 45e163c..e1c91b7 100644 --- a/scripts/build-managed-auth-app.mjs +++ b/scripts/build-managed-auth-app.mjs @@ -1,3 +1,8 @@ +// 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 Bun version pinned in +// .github/workflows/ci.yml (currently 1.3.3). Regenerate the bundle with that +// exact version: 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"; diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 77648bb..b97d764 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -6,6 +6,30 @@ import { verifyToken } from "@clerk/nextjs/server"; import { NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; import { registerMcpCapabilities } from "@/lib/mcp/register"; +import { initializeDeclaresMcpApps } from "@/lib/mcp/tools/auth-login-app"; +import { markMcpAppsClient } from "@/lib/redis"; + +// The streamable-HTTP transport is stateless (one McpServer per request), so +// the client's declared MCP Apps capability is invisible to later tool calls. +// Observe initialize here — where the bearer token is available — and record +// the capability per token so app-only tools can fail closed on hosts that +// never declared MCP Apps support. +async function recordMcpAppsCapability( + req: NextRequest, + token: string, + ttlSeconds: number, +): Promise { + if (req.method !== "POST") return; + try { + const body = await req.clone().json(); + if (!initializeDeclaresMcpApps(body)) return; + await markMcpAppsClient({ token, ttlSeconds }); + } catch (error) { + // Never block the initialize handshake on the marker; gated tools fail + // closed if the record is missing. + console.error("Failed to record MCP Apps capability:", error); + } +} export async function OPTIONS(_req: NextRequest): Promise { return new Response(null, { @@ -59,6 +83,9 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } if (!isValidJwtFormat(token)) { + // Static API keys have no expiry; use a long sliding TTL (refreshed on + // every gated app-only call and on re-initialize). + await recordMcpAppsCapability(req, token, 24 * 60 * 60); const authHandler = withMcpAuth( handler, async () => ({ @@ -87,6 +114,14 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { ); } + // Bind the capability marker to this token's own lifetime: when the token + // dies the client must re-authenticate (and re-initialize) anyway. + const tokenTtlSeconds = + typeof payload.exp === "number" + ? Math.max(60, payload.exp - Math.floor(Date.now() / 1000)) + : 60 * 60; + await recordMcpAppsCapability(req, token, tokenTtlSeconds); + // Create authenticated handler with auth info const authHandler = withMcpAuth( handler, diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index 2f65d27..3bcd63f 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +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
Preparing secure login…
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index faa1e61..44b4c30 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -64,6 +64,12 @@ type BeginResult = { let nextRequestId = 1; const pendingRequests = new Map(); +// Reply target origin learned from the first validated host message. Outbound +// messages can carry capability-bearing URLs (the hosted fallback via +// ui/open-link), so once the host's origin is known we stop broadcasting to +// "*". The "*" fallback only exists because opaque-origin sandbox iframes +// cannot know the host origin before the first inbound message. +let hostOrigin: string | null = null; let destroyed = false; let collapsed = false; let reactRoot: Root | null = null; @@ -91,16 +97,25 @@ function notifyStateChanged() { for (const listener of listeners) listener(); } +function postToHost(message: JsonObject) { + // The "*" fallback is only reachable before the host's first inbound + // message (opaque-origin sandboxes cannot know the host origin earlier); + // those early messages are the ui/initialize handshake and carry no + // capability-bearing data. Everything after is pinned to hostOrigin. + // nosemgrep + window.parent.postMessage(message, hostOrigin ?? "*"); +} + function sendRequest(method: string, params: JsonObject): Promise { const id = nextRequestId++; return new Promise((resolve, reject) => { pendingRequests.set(id, { resolve, reject }); - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); + postToHost({ jsonrpc: "2.0", id, method, params }); }); } function sendNotification(method: string, params: JsonObject) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); + postToHost({ jsonrpc: "2.0", method, params }); } function callTool(name: string, args: JsonObject): Promise { @@ -134,6 +149,11 @@ function reportSize() { window.addEventListener("message", (event: MessageEvent) => { if (event.source !== window.parent) return; + // Pin replies to the host's origin once known. Ignore "null" (opaque + // origins): they cannot be targeted, so the "*" fallback stays in effect. + if (!hostOrigin && event.origin && event.origin !== "null") { + hostOrigin = event.origin; + } const message = event.data as | { jsonrpc?: string; @@ -167,18 +187,12 @@ window.addEventListener("message", (event: MessageEvent) => { listeners.clear(); reactRoot?.unmount(); reactRoot = null; - window.parent.postMessage( - { jsonrpc: "2.0", id: message.id, result: {} }, - "*", - ); + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); return; } if (message.id !== undefined && message.method) { - window.parent.postMessage( - { jsonrpc: "2.0", id: message.id, result: {} }, - "*", - ); + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); return; } diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index fcab85a..24affcb 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -271,6 +271,169 @@ describe("managed-auth wait", () => { expect(result.state).toBe("pending"); }); + test("treats authenticated with a live re-auth flow as pending, even without a flow guard", async () => { + // Text-only re-auth of an AUTHENTICATED connection: the wait must not + // accept the pre-flow authenticated state while the new flow runs. + const liveReauth = connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }); + const client = { + auth: { connections: { retrieve: async () => liveReauth } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: liveReauth.id }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + + test("accepts authenticated once the live flow succeeds, without any flow guard", async () => { + const states = [ + connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: "conn_1" }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("authenticated"); + }); + + test("baseline-guarded reauth wait completes when the server reports flow_type LOGIN", async () => { + // The server chooses the flow type; a reauth-mode wait must accept any + // successful new flow instead of assuming REAUTH. + const states = [ + connection({ + status: "NEEDS_AUTH", + flow_status: "IN_PROGRESS", + flow_type: "LOGIN", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "LOGIN", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: "conn_1", previousFlowExpiresAt: null }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("authenticated"); + }); + + test("baseline-guarded wait stays pending on the stale pre-flow success", async () => { + const stale = connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "LOGIN", + flow_expires_at: "2026-01-01T00:00:00Z", + }); + const client = { + auth: { connections: { retrieve: async () => stale } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: stale.id, + previousFlowExpiresAt: stale.flow_expires_at, + }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + + test("baseline-guarded wait accepts success after observing the new live flow, even if the expiry matches the baseline", async () => { + // Backstop for servers that keep (or clear) flow_expires_at once a flow + // reaches a terminal state: having observed the new in-progress flow is + // proof the success is not the stale pre-flow one. + const states = [ + connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: "conn_1", + previousFlowExpiresAt: "2099-01-01T00:00:00Z", + }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("authenticated"); + }); + + test("baseline-guarded wait does not fail on a terminal flow that predates the baseline", async () => { + const oldFailure = connection({ + status: "NEEDS_AUTH", + flow_status: "FAILED", + flow_type: "LOGIN", + flow_expires_at: "2020-01-01T00:00:00Z", + }); + const client = { + auth: { connections: { retrieve: async () => oldFailure } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: oldFailure.id, + previousFlowExpiresAt: "2020-01-01T00:00:00Z", + }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + test("returns safe failure and pending states", async () => { const failedClient = { auth: { diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 6b62de0..ba878e3 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -30,6 +30,7 @@ export function registerAuthConnectionTools(server: McpServer) { action: z.enum(["list", "get", "wait"]).describe("Read operation."), id: z .string() + .min(1) .describe("Auth connection ID. Required for get and re-auth wait.") .optional(), profile_name: z diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index a7df0ba..a5c9cac 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -1,19 +1,46 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; import { + initializeDeclaresMcpApps, MANAGED_AUTH_MIME_TYPE, MANAGED_AUTH_RESOURCE_URI, managedAuthResourceMeta, registerAuthLoginApp, } from "@/lib/mcp/tools/auth-login-app"; +// Tests that exercise API-backed handlers substitute a fake Kernel client. +// The default stub errors if any API method is actually invoked. +const unusedKernelClient = new Proxy( + {}, + { + get: () => { + throw new Error("unexpected Kernel client use"); + }, + }, +); +let kernelClientFactory: (token: string) => any = () => unusedKernelClient; +function resetKernelClientFactory() { + kernelClientFactory = () => unusedKernelClient; +} + +// The capability gate falls back to a Redis marker (recorded by the route +// layer at initialize) on stateless transports. Tests control it directly. +let redisMarkerPresent = false; +mock.module("@/lib/redis", () => ({ + hasMcpAppsClient: async () => redisMarkerPresent, + markMcpAppsClient: async () => {}, +})); +mock.module("@/lib/mcp/kernel-client", () => ({ + createKernelClient: (token: string) => kernelClientFactory(token), +})); + type ToolRegistration = { config: Record; handler: (params: any, extra: any) => Promise; }; -function captureRegistration() { +function captureRegistration({ appsSupport = true } = {}) { const tools = new Map(); let resource: | { @@ -22,20 +49,22 @@ function captureRegistration() { handler: (uri: URL) => Promise; } | undefined; - const appCapability = { - extensions: { - "io.modelcontextprotocol/ui": { - mimeTypes: [MANAGED_AUTH_MIME_TYPE], - }, - }, - }; + const clientCapabilities = appsSupport + ? { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [MANAGED_AUTH_MIME_TYPE], + }, + }, + } + : {}; const registrationHandle = () => ({ enable() {}, disable() {}, }); const server = { server: { - getClientCapabilities: () => appCapability, + getClientCapabilities: () => clientCapabilities, }, registerResource( _name: string, @@ -131,18 +160,275 @@ describe("managed-auth MCP App registration", () => { wait_seconds: 25, }, }, - app_capability: expect.any(String), }); expect(JSON.stringify(result)).not.toContain("?code="); expect(JSON.stringify(result)).not.toContain("handoff_code"); expect(JSON.stringify(result)).not.toContain("hosted_url"); + // The delete authorization capability travels only in _meta, never in + // model-visible content or structuredContent. expect(result._meta.auth_login_launcher.app_capability).toBeString(); expect(JSON.stringify(result.content)).not.toContain("app_capability"); - expect(result.structuredContent.app_capability).toBe( - result._meta.auth_login_launcher.app_capability, + expect(JSON.stringify(result.structuredContent)).not.toContain( + "app_capability", ); }); + test("launcher issues no app_capability to hosts without MCP Apps support", async () => { + const { tools } = captureRegistration({ appsSupport: false }); + const result = await tools.get("open_auth_login")!.handler( + { + mode: "new_login", + domain: "example.com", + profile_name: "work", + text_only: false, + }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result._meta).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain("app_capability"); + }); + + test("app-only tools fail closed on hosts without MCP Apps support", async () => { + const { tools } = captureRegistration({ appsSupport: false }); + const calls: Array<[string, any]> = [ + ["begin_auth_login", { mode: "reauth", connection_id: "conn_1" }], + ["get_auth_login_status", { connection_id: "conn_1" }], + [ + "delete_auth_login_connection", + { connection_id: "conn_1", app_capability: "forged" }, + ], + ]; + for (const [name, params] of calls) { + const result = await tools.get(name)!.handler(params, { + authInfo: { token: "unused-api-key" }, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("MCP Apps-capable hosts"); + expect(JSON.stringify(result)).not.toContain("handoff_code"); + expect(JSON.stringify(result)).not.toContain("hosted_url"); + } + }); + + test("stateless transports pass the gate via the recorded initialize marker", async () => { + // Simulates the streamable-HTTP path: no client capabilities on the + // per-request server, but the route layer recorded the capability. + redisMarkerPresent = true; + try { + const { tools } = captureRegistration({ appsSupport: false }); + const result = await tools + .get("delete_auth_login_connection")! + .handler( + { connection_id: "conn_1", app_capability: "forged" }, + { authInfo: { token: "unused-api-key" } }, + ); + // Past the gate: fails on the forged capability, not the gate. + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("authorization is invalid"); + + const launcher = await tools.get("open_auth_login")!.handler( + { + mode: "new_login", + domain: "example.com", + profile_name: "work", + text_only: false, + }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(launcher._meta.auth_login_launcher.app_capability).toBeString(); + } finally { + redisMarkerPresent = false; + } + }); + + test("detects MCP Apps declarations in initialize payloads", () => { + expect( + initializeDeclaresMcpApps({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [MANAGED_AUTH_MIME_TYPE], + }, + }, + }, + clientInfo: { name: "qa", version: "1" }, + }, + }), + ).toBe(true); + expect( + initializeDeclaresMcpApps([ + { jsonrpc: "2.0", method: "notifications/initialized" }, + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + capabilities: { + extensions: { "io.modelcontextprotocol/ui": {} }, + }, + }, + }, + ]), + ).toBe(true); + expect( + initializeDeclaresMcpApps({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { capabilities: {} }, + }), + ).toBe(false); + expect( + initializeDeclaresMcpApps({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "begin_auth_login", + arguments: { + capabilities: { + extensions: { "io.modelcontextprotocol/ui": {} }, + }, + }, + }, + }), + ).toBe(false); + expect(initializeDeclaresMcpApps(null)).toBe(false); + expect(initializeDeclaresMcpApps("not json")).toBe(false); + }); + + test("reauth launcher guards the wait with the pre-flow baseline, never a guessed flow type", async () => { + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => ({ + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "LOGIN", + flow_expires_at: "2026-01-01T00:00:00Z", + }), + }, + }, + }); + try { + const { tools } = captureRegistration(); + const result = await tools + .get("open_auth_login")! + .handler( + { mode: "reauth", connection_id: "conn_1", text_only: false }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.structuredContent.next_action.arguments).toEqual({ + action: "wait", + id: "conn_1", + wait_seconds: 25, + previous_flow_expires_at: "2026-01-01T00:00:00Z", + }); + expect(JSON.stringify(result.structuredContent)).not.toContain( + "required_flow_type", + ); + } finally { + resetKernelClientFactory(); + } + }); + + test("reauth launcher observing a live flow emits no baseline guard", async () => { + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => ({ + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + }, + }, + }); + try { + const { tools } = captureRegistration(); + const result = await tools + .get("open_auth_login")! + .handler( + { mode: "reauth", connection_id: "conn_1", text_only: false }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.structuredContent.next_action.arguments).toEqual({ + action: "wait", + id: "conn_1", + wait_seconds: 25, + }); + } finally { + resetKernelClientFactory(); + } + }); + + test("text_only fallback emits baseline-guarded wait arguments and keeps handoff material out of model content", async () => { + const initial = { + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "NEEDS_AUTH", + flow_status: "FAILED", + flow_type: "LOGIN", + flow_expires_at: "2020-01-01T00:00:00Z", + }; + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => initial, + login: async () => ({ + id: "conn_1", + flow_type: "LOGIN", + flow_expires_at: "2099-01-01T00:00:00Z", + hosted_url: + "https://managed-auth.onkernel.com/login/conn_1?code=handoff-secret", + handoff_code: "handoff-secret", + }), + }, + }, + }); + try { + const { tools } = captureRegistration({ appsSupport: false }); + const result = await tools + .get("open_auth_login")! + .handler( + { mode: "reauth", connection_id: "conn_1", text_only: true }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.content[0].text).toContain( + JSON.stringify({ + action: "wait", + id: "conn_1", + wait_seconds: 25, + previous_flow_expires_at: "2020-01-01T00:00:00Z", + }), + ); + // The hosted URL is delivered only as user-audience text; nothing + // capability-bearing appears in structuredContent. + expect(result.content[1].text).toContain("handoff-secret"); + expect(result.content[1].annotations).toEqual({ audience: ["user"] }); + expect(JSON.stringify(result.structuredContent)).not.toContain( + "handoff-secret", + ); + expect(JSON.stringify(result.structuredContent)).not.toContain( + "hosted_url", + ); + } finally { + resetKernelClientFactory(); + } + }); + test("destructive app-only cleanup rejects calls without the private launcher capability", async () => { const { tools } = captureRegistration(); const result = await tools.get("delete_auth_login_connection")!.handler( diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 0c38917..97ddf3f 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -3,15 +3,81 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { hasMcpAppsClient } from "@/lib/redis"; import { AuthLoginStartError, beginAuthLogin, type AuthLoginInput, + hasLiveAuthFlow, toSafeAuthConnection, validateAuthLoginInput, } from "@/lib/mcp/tools/managed-auth-state"; import { errorResponse } from "@/lib/mcp/responses"; +// MCP Apps (SEP-1865) extension identifier. Clients that render MCP Apps +// declare it in their initialize capabilities. +const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui"; + +// Sliding TTL for the Redis capability marker. Long enough that an active App +// never loses it mid-flow; refreshed on every gated call. +const MCP_APPS_MARKER_TTL_SECONDS = 24 * 60 * 60; + +/** + * Whether a JSON-RPC payload (single message or batch) is an initialize that + * declares MCP Apps support. The route layer uses this to record the client + * capability, because the stateless streamable-HTTP transport does not expose + * it to later requests. + */ +export function initializeDeclaresMcpApps(body: unknown): boolean { + const messages = Array.isArray(body) ? body : [body]; + return messages.some((message) => { + if (!message || typeof message !== "object") return false; + const request = message as { + method?: unknown; + params?: { capabilities?: { extensions?: Record } }; + }; + return ( + request.method === "initialize" && + Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION]) + ); + }); +} + +/** + * App-only tools are hidden from the model via `_meta.ui.visibility`, but that + * is a hint hosts without MCP Apps support are free to ignore. Fail closed: + * only execute them when the connected client actually declared the MCP Apps + * extension. Persistent transports (SSE) expose client capabilities directly; + * on the stateless streamable-HTTP transport the route layer records the + * declared capability per bearer token at initialize time. + */ +async function clientSupportsMcpApps( + server: McpServer, + authToken: string, +): Promise { + const capabilities = server.server.getClientCapabilities() as + | { extensions?: Record } + | undefined; + if (capabilities?.extensions?.[MCP_APPS_EXTENSION]) return true; + try { + return await hasMcpAppsClient({ + token: authToken, + ttlSeconds: MCP_APPS_MARKER_TTL_SECONDS, + }); + } catch (error) { + console.error("MCP Apps capability check failed; failing closed:", error); + return false; + } +} + +async function mcpAppsGateError( + server: McpServer, + authToken: string, +): Promise { + if (await clientSupportsMcpApps(server, authToken)) return null; + return "This tool is only available to the secure Kernel login App on MCP Apps-capable hosts and cannot be called by the model. To authenticate without the App, call open_auth_login with text_only=true after the user confirms that no panel appeared."; +} + export const MANAGED_AUTH_RESOURCE_URI = "ui://kernel/managed-auth-login-v7.html"; export const MANAGED_AUTH_MIME_TYPE = "text/html;profile=mcp-app"; @@ -91,7 +157,7 @@ function validAppCapability(capability: string, authToken: string): boolean { const authLoginInputSchema = { mode: z.enum(["new_login", "reauth"]), - connection_id: z.string().optional(), + connection_id: z.string().min(1).optional(), domain: z.string().optional(), profile_name: z.string().optional(), save_credentials: z.boolean().optional(), @@ -172,6 +238,19 @@ export function registerAuthLoginApp(server: McpServer) { try { if (params.text_only) { const result = await beginAuthLogin(client, input); + // Guard the wait with the pre-flow baseline captured by begin so a + // completed flow from before this call is never accepted as the new + // one. When begin only observes an already-live flow, the wait + // tracks that flow directly (a live in-progress flow reads as + // pending even on an AUTHENTICATED connection). + const waitArguments: Record = { + action: "wait", + id: result.connection.id, + wait_seconds: 25, + ...(result.started_new_flow && { + previous_flow_expires_at: result.previous_flow_expires_at, + }), + }; const content: Array<{ type: "text"; text: string; @@ -179,7 +258,7 @@ export function registerAuthLoginApp(server: McpServer) { }> = [ { type: "text", - text: `Secure managed authentication is ${result.state === "already_authenticated" ? "already complete" : "ready"} for connection ${result.connection.id}. Expiry: ${result.connection.flow_expires_at ?? "not applicable"}. Do not claim success from this response. Immediately call manage_auth_connections with action=wait, id=${result.connection.id}, and wait_seconds=25; repeat while pending and continue only when it returns authenticated.`, + text: `Secure managed authentication is ${result.state === "already_authenticated" ? "already complete" : "ready"} for connection ${result.connection.id}. Expiry: ${result.connection.flow_expires_at ?? "not applicable"}. Do not claim success from this response. Immediately call manage_auth_connections with ${JSON.stringify(waitArguments)}; repeat with the same arguments while it returns state=pending and continue only when it returns state=authenticated.`, }, ]; if (result.hosted_url) { @@ -216,11 +295,13 @@ export function registerAuthLoginApp(server: McpServer) { action: "wait", id: input.connection_id!, wait_seconds: 25, - required_flow_type: "REAUTH", - ...(reauthConnection.status === "AUTHENTICATED" && - reauthConnection.flow_status !== "IN_PROGRESS" && { - previous_flow_expires_at: reauthConnection.flow_expires_at, - }), + // The server chooses LOGIN vs REAUTH when the flow starts, so + // never guess a required flow type here. Guard on the pre-flow + // baseline instead. When a flow is already live the wait simply + // observes that flow, so no baseline is needed. + ...(!hasLiveAuthFlow(reauthConnection) && { + previous_flow_expires_at: reauthConnection.flow_expires_at, + }), } : { action: "wait", @@ -228,7 +309,16 @@ export function registerAuthLoginApp(server: McpServer) { profile_name: input.profile_name!, wait_seconds: 25, }; - const appCapability = issueAppCapability(extra.authInfo.token); + // The delete authorization capability is App-only: issue it solely to + // hosts that declared MCP Apps support, and keep it in _meta (which + // hosts do not add to model context). It never goes in + // structuredContent, which non-Apps hosts may surface to the model. + const appCapability = (await clientSupportsMcpApps( + server, + extra.authInfo.token, + )) + ? issueAppCapability(extra.authInfo.token) + : null; return { content: [ { @@ -246,17 +336,14 @@ export function registerAuthLoginApp(server: McpServer) { tool: "manage_auth_connections", arguments: waitArguments, }, - // MCP Apps structuredContent is delivered to the View but is not - // added to model context. Claude currently strips tool-result - // _meta from launcher notifications, so duplicate this short-lived, - // API-token-bound capability here for host compatibility. - app_capability: appCapability, }, - _meta: { - auth_login_launcher: { - app_capability: appCapability, + ...(appCapability && { + _meta: { + auth_login_launcher: { + app_capability: appCapability, + }, }, - }, + }), }; } catch (error) { return errorResponse( @@ -285,6 +372,8 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); + const gateError = await mcpAppsGateError(server, extra.authInfo.token); + if (gateError) return errorResponse(gateError); const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); if (validationError) return errorResponse(`Error: ${validationError}`); @@ -313,9 +402,11 @@ export function registerAuthLoginApp(server: McpServer) { connection: result.connection, started_new_flow: result.started_new_flow, resume_id: result.resume_id, - // This helper is visibility:["app"] and cannot be called by the - // model on compliant hosts. Duplicate App-private flow material - // here because Claude may omit tool-result _meta. + // Execution is gated on the client's MCP Apps capability, so + // this result only reaches hosts that deliver visibility:["app"] + // tool results to the View rather than the model. The + // structuredContent duplicate exists because Claude may omit + // tool-result _meta. app_private: appPrivate, }, _meta: { @@ -339,7 +430,7 @@ export function registerAuthLoginApp(server: McpServer) { description: "Read sanitized managed-auth status for the secure login App.", inputSchema: { - connection_id: z.string(), + connection_id: z.string().min(1), }, annotations: { readOnlyHint: true, @@ -351,6 +442,8 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); + const gateError = await mcpAppsGateError(server, extra.authInfo.token); + if (gateError) return errorResponse(gateError); const client = createKernelClient(extra.authInfo.token); try { const connection = toSafeAuthConnection( @@ -384,7 +477,7 @@ export function registerAuthLoginApp(server: McpServer) { description: "Delete the managed-auth connection created by the secure App, including QA cleanup.", inputSchema: { - connection_id: z.string(), + connection_id: z.string().min(1), app_capability: z.string(), }, annotations: { @@ -397,6 +490,8 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); + const gateError = await mcpAppsGateError(server, extra.authInfo.token); + if (gateError) return errorResponse(gateError); if (!validAppCapability(params.app_capability, extra.authInfo.token)) { return errorResponse("Secure App authorization is invalid or expired."); } diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts index 2348a3d..18ffd81 100644 --- a/src/lib/mcp/tools/live-view-app.ts +++ b/src/lib/mcp/tools/live-view-app.ts @@ -177,6 +177,8 @@ const LIVE_VIEW_HTML = /* html */ ` }; window.addEventListener("message", function (event) { + // Only the embedding host frame may drive this view. + if (event.source !== window.parent) return; var msg = event.data; if (!msg || msg.jsonrpc !== "2.0") return; // Response to one of our requests diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index fd6a60c..5c9b878 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -80,6 +80,12 @@ export interface BeginAuthLoginResult { connection: SafeAuthConnection; started_new_flow: boolean; resume_id: string; + /** + * The connection's flow_expires_at captured before any new flow was started. + * Waiters use it as a baseline so a completed flow from before this begin + * call is never mistaken for the newly requested one. + */ + previous_flow_expires_at: string | null; handoff_code?: string; hosted_url?: string; } @@ -200,6 +206,7 @@ export async function waitForAuthConnection( const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 1_000); const deadline = Date.now() + timeoutMs; let observedQuery = false; + let observedLiveFlow = false; let latest: SafeAuthConnection | undefined; do { @@ -211,13 +218,31 @@ export async function waitForAuthConnection( observedQuery = true; if (connection) { latest = toSafeAuthConnection(connection); - const requiredFlowCompleted = selector.requiredFlowType - ? latest.flow_status === "SUCCESS" && - latest.flow_type === selector.requiredFlowType && + if (hasLiveAuthFlow(latest)) observedLiveFlow = true; + // A wait is flow-guarded when the caller supplied a baseline (and/or a + // required flow type): only a flow that completed after that baseline + // counts. The flow type is never assumed ahead of time — the server + // chooses LOGIN vs REAUTH — so any successful new flow satisfies the + // guard. The observed-live-flow backstop covers servers that clear + // flow_expires_at once a flow reaches a terminal state. + const flowGuarded = + selector.requiredFlowType !== undefined || + selector.previousFlowExpiresAt !== undefined; + const requiredFlowCompleted = + !flowGuarded || + (latest.flow_status === "SUCCESS" && + (selector.requiredFlowType === undefined || + latest.flow_type === selector.requiredFlowType) && (selector.previousFlowExpiresAt === undefined || - latest.flow_expires_at !== selector.previousFlowExpiresAt) - : true; - if (latest.status === "AUTHENTICATED" && requiredFlowCompleted) { + latest.flow_expires_at !== selector.previousFlowExpiresAt || + observedLiveFlow)); + // AUTHENTICATED with a live in-progress flow means a (re-)auth is + // still running: report pending instead of the stale pre-flow state. + if ( + latest.status === "AUTHENTICATED" && + !hasLiveAuthFlow(latest) && + requiredFlowCompleted + ) { return { state: "authenticated", connection: latest }; } if ( @@ -225,7 +250,17 @@ export async function waitForAuthConnection( latest.flow_status === "EXPIRED" || latest.flow_status === "CANCELED" ) { - return { state: "failed", connection: latest }; + // In flow-guarded mode a terminal flow matching the baseline predates + // this wait (e.g. an old failed attempt); keep polling for the new + // flow instead of reporting a stale failure. + const terminalIsBaseline = + flowGuarded && + !observedLiveFlow && + selector.previousFlowExpiresAt !== undefined && + latest.flow_expires_at === selector.previousFlowExpiresAt; + if (!terminalIsBaseline) { + return { state: "failed", connection: latest }; + } } } } catch (error) { @@ -443,6 +478,7 @@ function readyResult( connection: ManagedAuth, options: { startedNewFlow: boolean; + previousFlowExpiresAt: string | null; handoffCode?: string; hostedUrl?: string; }, @@ -456,6 +492,7 @@ function readyResult( connection: toSafeAuthConnection(connection), started_new_flow: options.startedNewFlow, resume_id: randomResumeId(), + previous_flow_expires_at: options.previousFlowExpiresAt, ...(options.handoffCode && { handoff_code: options.handoffCode }), ...(options.hostedUrl && { hosted_url: options.hostedUrl }), }; @@ -498,11 +535,16 @@ export async function beginAuthLogin( connection = await client.auth.connections.retrieve(input.connection_id!); } + const previousFlowExpiresAt = connection.flow_expires_at ?? null; + if (hasLiveAuthFlow(connection, now)) { // Handoff codes embedded in hosted_url are single-use. An existing flow may // already have redeemed its code in another panel, so reopening is strictly // observation-only until the API can mint a fresh resume capability. - return readyResult(connection, { startedNewFlow: false }); + return readyResult(connection, { + startedNewFlow: false, + previousFlowExpiresAt, + }); } if (input.mode === "new_login" && connection.status === "AUTHENTICATED") { @@ -511,6 +553,7 @@ export async function beginAuthLogin( connection: toSafeAuthConnection(connection), started_new_flow: false, resume_id: randomResumeId(), + previous_flow_expires_at: previousFlowExpiresAt, }; } @@ -536,6 +579,7 @@ export async function beginAuthLogin( } return readyResult(current, { startedNewFlow: true, + previousFlowExpiresAt, handoffCode: login.handoff_code, hostedUrl: login.hosted_url, }); diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 54e349a..9f6f4ac 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -148,6 +148,57 @@ export async function setOrgIdForJwt({ export { client as redisClient }; +// MCP Apps capability markers. The streamable-HTTP transport is stateless +// (one McpServer per request), so a client's declared +// `io.modelcontextprotocol/ui` capability from initialize is not visible to +// later tool calls on the same connection. The route layer records it here, +// keyed by the bearer token, so app-only tools can fail closed on hosts that +// never declared MCP Apps support. +const MCP_APPS_KEY_PREFIX = "mcp-apps:"; + +function hashBearerToken(token: string): string { + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return createHmac("sha256", secretKey).update(token).digest("hex"); +} + +export async function markMcpAppsClient({ + token, + ttlSeconds, +}: { + token: string; + ttlSeconds: number; +}): Promise { + await ensureConnected(); + const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`; + await withReconnect(() => + client.setEx(key, Math.max(60, Math.floor(ttlSeconds)), "1"), + ); +} + +/** + * Whether the bearer token's client declared MCP Apps support at initialize. + * Sliding expiration: active App sessions keep the marker alive. + */ +export async function hasMcpAppsClient({ + token, + ttlSeconds, +}: { + token: string; + ttlSeconds: number; +}): Promise { + await ensureConnected(); + const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`; + const value = await withReconnect(() => client.get(key)); + if (value === null) return false; + await withReconnect(() => + client.expire(key, Math.max(60, Math.floor(ttlSeconds))), + ); + return true; +} + export async function setOrgIdForRefreshToken({ refreshToken, orgId, From 55b2569fb59e5dbbdcdebeea0eb037cc581d2a4a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 10:25:57 -0400 Subject: [PATCH 06/12] fix: close Bugbot round-2 findings on managed-auth MCP app - delete_auth_login_connection: app_capability now z.string().min(1) so an empty capability fails Zod validation before HMAC verification. - waitForAuthConnection: when the wait observed a live flow that then reached a terminal failure, report failed even if the connection still reads AUTHENTICATED (a failed re-auth keeps its previous session and the App shows the failure). Terminal failures with no live flow observed still predate the wait and leave authenticated state usable. - deriveAuthNextAction: an AUTHENTICATED connection with a live in-progress flow now steers discovery to manage_auth_connections wait instead of manage_browsers, matching waitForAuthConnection's pending semantics. - capture_live_view_frame: fail closed like the other app-only tools. The MCP Apps capability gate moves to a shared mcp-apps-gate module and the handler now requires an Apps-capable host (client capabilities or the Redis initialize marker), so hosts that ignore visibility cannot let the model poll screenshots with the user's bearer token. Regression tests: observed-live-flow failure vs stale terminal failure in unguarded waits, live-flow discovery steering, app-only schema min(1), and the live view gate (fail-closed, Redis-marker, and capability paths). --- src/lib/mcp/tools/auth-connections.test.ts | 76 ++++++++++++ src/lib/mcp/tools/auth-login-app.test.ts | 22 ++++ src/lib/mcp/tools/auth-login-app.ts | 91 ++++----------- src/lib/mcp/tools/live-view-app.test.ts | 127 +++++++++++++++++++++ src/lib/mcp/tools/live-view-app.ts | 10 ++ src/lib/mcp/tools/managed-auth-state.ts | 32 +++++- src/lib/mcp/tools/mcp-apps-gate.ts | 71 ++++++++++++ 7 files changed, 357 insertions(+), 72 deletions(-) create mode 100644 src/lib/mcp/tools/live-view-app.test.ts create mode 100644 src/lib/mcp/tools/mcp-apps-gate.ts diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index 24affcb..1373581 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -187,6 +187,26 @@ describe("managed-auth safe responses", () => { expect(authenticated.next_action?.tool).toBe("manage_browsers"); expect(authenticated.next_action?.arguments.profile_name).toBe("work"); + const liveReauth = deriveAuthNextAction({ + items: [ + { + ...safe, + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }, + ], + hasMore: false, + nextOffset: null, + domainFilter: "example.com", + }); + expect(liveReauth.next_action?.tool).toBe("manage_auth_connections"); + expect(liveReauth.next_action?.arguments).toEqual({ + action: "wait", + id: "conn_1", + }); + const multiple = deriveAuthNextAction({ items: [safe, { ...safe, id: "conn_2", profile_name: "personal" }], hasMore: false, @@ -322,6 +342,62 @@ describe("managed-auth wait", () => { expect(result.state).toBe("authenticated"); }); + test("reports failure when an observed live flow fails on an authenticated connection, even without a flow guard", async () => { + // A failed re-auth keeps the previous session, so the connection still + // reads AUTHENTICATED. Because this wait saw the flow live, the failure + // is authoritative: the App shows it, so the wait must not resume the + // agent with a stale success. + const states = [ + connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + status: "AUTHENTICATED", + flow_status: "FAILED", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: "conn_1" }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("failed"); + }); + + test("still accepts authenticated state with a stale terminal failure, without any flow guard", async () => { + // No live flow observed during this wait: the failed flow predates it, + // so the connection's authenticated state is usable (duplicate-recovery + // path). + const stale = connection({ + status: "AUTHENTICATED", + flow_status: "FAILED", + flow_type: "REAUTH", + flow_expires_at: "2026-01-01T00:00:00Z", + }); + const client = { + auth: { connections: { retrieve: async () => stale } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: stale.id }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("authenticated"); + }); + test("baseline-guarded reauth wait completes when the server reports flow_type LOGIN", async () => { // The server chooses the flow type; a reauth-mode wait must accept any // successful new flow instead of assuming REAUTH. diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index a5c9cac..b47f021 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; import { initializeDeclaresMcpApps, @@ -134,6 +135,27 @@ describe("managed-auth MCP App registration", () => { ).toEqual(["app"]); }); + test("app-only tool schemas reject empty identifiers", () => { + const { tools } = captureRegistration(); + const deleteSchema = z.object( + tools.get("delete_auth_login_connection")!.config.inputSchema, + ); + expect( + deleteSchema.safeParse({ + connection_id: "conn_1", + app_capability: "", + }).success, + ).toBe(false); + expect( + deleteSchema.safeParse({ connection_id: "", app_capability: "cap" }) + .success, + ).toBe(false); + const statusSchema = z.object( + tools.get("get_auth_login_status")!.config.inputSchema, + ); + expect(statusSchema.safeParse({ connection_id: "" }).success).toBe(false); + }); + test("normal launcher creates no backend flow or managed-auth handoff", async () => { const { tools } = captureRegistration(); const result = await tools.get("open_auth_login")!.handler( diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 97ddf3f..d7063a6 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -3,7 +3,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; import { createKernelClient } from "@/lib/mcp/kernel-client"; -import { hasMcpAppsClient } from "@/lib/redis"; +import { + clientSupportsMcpApps, + initializeDeclaresMcpApps, + mcpAppsGateError, +} from "@/lib/mcp/tools/mcp-apps-gate"; import { AuthLoginStartError, beginAuthLogin, @@ -14,69 +18,10 @@ import { } from "@/lib/mcp/tools/managed-auth-state"; import { errorResponse } from "@/lib/mcp/responses"; -// MCP Apps (SEP-1865) extension identifier. Clients that render MCP Apps -// declare it in their initialize capabilities. -const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui"; - -// Sliding TTL for the Redis capability marker. Long enough that an active App -// never loses it mid-flow; refreshed on every gated call. -const MCP_APPS_MARKER_TTL_SECONDS = 24 * 60 * 60; - -/** - * Whether a JSON-RPC payload (single message or batch) is an initialize that - * declares MCP Apps support. The route layer uses this to record the client - * capability, because the stateless streamable-HTTP transport does not expose - * it to later requests. - */ -export function initializeDeclaresMcpApps(body: unknown): boolean { - const messages = Array.isArray(body) ? body : [body]; - return messages.some((message) => { - if (!message || typeof message !== "object") return false; - const request = message as { - method?: unknown; - params?: { capabilities?: { extensions?: Record } }; - }; - return ( - request.method === "initialize" && - Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION]) - ); - }); -} +export { initializeDeclaresMcpApps }; -/** - * App-only tools are hidden from the model via `_meta.ui.visibility`, but that - * is a hint hosts without MCP Apps support are free to ignore. Fail closed: - * only execute them when the connected client actually declared the MCP Apps - * extension. Persistent transports (SSE) expose client capabilities directly; - * on the stateless streamable-HTTP transport the route layer records the - * declared capability per bearer token at initialize time. - */ -async function clientSupportsMcpApps( - server: McpServer, - authToken: string, -): Promise { - const capabilities = server.server.getClientCapabilities() as - | { extensions?: Record } - | undefined; - if (capabilities?.extensions?.[MCP_APPS_EXTENSION]) return true; - try { - return await hasMcpAppsClient({ - token: authToken, - ttlSeconds: MCP_APPS_MARKER_TTL_SECONDS, - }); - } catch (error) { - console.error("MCP Apps capability check failed; failing closed:", error); - return false; - } -} - -async function mcpAppsGateError( - server: McpServer, - authToken: string, -): Promise { - if (await clientSupportsMcpApps(server, authToken)) return null; - return "This tool is only available to the secure Kernel login App on MCP Apps-capable hosts and cannot be called by the model. To authenticate without the App, call open_auth_login with text_only=true after the user confirms that no panel appeared."; -} +const MCP_APPS_GATE_DENIED_MESSAGE = + "This tool is only available to the secure Kernel login App on MCP Apps-capable hosts and cannot be called by the model. To authenticate without the App, call open_auth_login with text_only=true after the user confirms that no panel appeared."; export const MANAGED_AUTH_RESOURCE_URI = "ui://kernel/managed-auth-login-v7.html"; @@ -372,7 +317,11 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const gateError = await mcpAppsGateError(server, extra.authInfo.token); + const gateError = await mcpAppsGateError( + server, + extra.authInfo.token, + MCP_APPS_GATE_DENIED_MESSAGE, + ); if (gateError) return errorResponse(gateError); const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); @@ -442,7 +391,11 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const gateError = await mcpAppsGateError(server, extra.authInfo.token); + const gateError = await mcpAppsGateError( + server, + extra.authInfo.token, + MCP_APPS_GATE_DENIED_MESSAGE, + ); if (gateError) return errorResponse(gateError); const client = createKernelClient(extra.authInfo.token); try { @@ -478,7 +431,7 @@ export function registerAuthLoginApp(server: McpServer) { "Delete the managed-auth connection created by the secure App, including QA cleanup.", inputSchema: { connection_id: z.string().min(1), - app_capability: z.string(), + app_capability: z.string().min(1), }, annotations: { readOnlyHint: false, @@ -490,7 +443,11 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const gateError = await mcpAppsGateError(server, extra.authInfo.token); + const gateError = await mcpAppsGateError( + server, + extra.authInfo.token, + MCP_APPS_GATE_DENIED_MESSAGE, + ); if (gateError) return errorResponse(gateError); if (!validAppCapability(params.app_capability, extra.authInfo.token)) { return errorResponse("Secure App authorization is invalid or expired."); diff --git a/src/lib/mcp/tools/live-view-app.test.ts b/src/lib/mcp/tools/live-view-app.test.ts new file mode 100644 index 0000000..cd798dc --- /dev/null +++ b/src/lib/mcp/tools/live-view-app.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerLiveViewApp } from "@/lib/mcp/tools/live-view-app"; + +// Tests that exercise API-backed handlers substitute a fake Kernel client. +const unusedKernelClient = new Proxy( + {}, + { + get: () => { + throw new Error("unexpected Kernel client use"); + }, + }, +); +let kernelClientFactory: (token: string) => any = () => unusedKernelClient; + +// The capability gate falls back to a Redis marker (recorded by the route +// layer at initialize) on stateless transports. Tests control it directly. +let redisMarkerPresent = false; +mock.module("@/lib/redis", () => ({ + hasMcpAppsClient: async () => redisMarkerPresent, + markMcpAppsClient: async () => {}, +})); +mock.module("@/lib/mcp/kernel-client", () => ({ + createKernelClient: (token: string) => kernelClientFactory(token), +})); + +type ToolRegistration = { + config: Record; + handler: (params: any, extra: any) => Promise; +}; + +function captureRegistration({ appsSupport = true } = {}) { + const tools = new Map(); + const clientCapabilities = appsSupport + ? { + extensions: { + "io.modelcontextprotocol/ui": {}, + }, + } + : {}; + const registrationHandle = () => ({ + enable() {}, + disable() {}, + }); + const server = { + server: { + getClientCapabilities: () => clientCapabilities, + }, + registerResource() { + return registrationHandle(); + }, + registerTool( + name: string, + config: Record, + handler: ToolRegistration["handler"], + ) { + tools.set(name, { config, handler }); + return registrationHandle(); + }, + } as unknown as McpServer; + registerLiveViewApp(server); + return { tools }; +} + +const authedExtra = { authInfo: { token: "test-token" } }; + +function fakeScreenshotClient() { + const png = Buffer.from("fake-png-bytes"); + return { + browsers: { + computer: { + captureScreenshot: async () => ({ + blob: async () => new Blob([png], { type: "image/png" }), + }), + }, + }, + }; +} + +describe("live view MCP App", () => { + test("capture_live_view_frame fails closed on hosts without MCP Apps support", async () => { + redisMarkerPresent = false; + const { tools } = captureRegistration({ appsSupport: false }); + const result = await tools + .get("capture_live_view_frame")! + .handler({ session_id: "sess_1" }, authedExtra); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + "only available to the embedded Kernel live view App", + ); + }); + + test("capture_live_view_frame passes the gate via the recorded initialize marker", async () => { + redisMarkerPresent = true; + kernelClientFactory = () => fakeScreenshotClient(); + try { + const { tools } = captureRegistration({ appsSupport: false }); + const result = await tools + .get("capture_live_view_frame")! + .handler({ session_id: "sess_1" }, authedExtra); + expect(result.isError).toBeUndefined(); + expect(result.content[0].type).toBe("image"); + expect(result.content[0].mimeType).toBe("image/png"); + expect(Buffer.from(result.content[0].data, "base64").toString()).toBe( + "fake-png-bytes", + ); + } finally { + redisMarkerPresent = false; + kernelClientFactory = () => unusedKernelClient; + } + }); + + test("capture_live_view_frame executes for MCP Apps-capable hosts", async () => { + redisMarkerPresent = false; + kernelClientFactory = () => fakeScreenshotClient(); + try { + const { tools } = captureRegistration({ appsSupport: true }); + const result = await tools + .get("capture_live_view_frame")! + .handler({ session_id: "sess_1" }, authedExtra); + expect(result.isError).toBeUndefined(); + expect(result.content[0].type).toBe("image"); + } finally { + kernelClientFactory = () => unusedKernelClient; + } + }); +}); diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts index 18ffd81..1584a05 100644 --- a/src/lib/mcp/tools/live-view-app.ts +++ b/src/lib/mcp/tools/live-view-app.ts @@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; import { errorResponse, toolErrorResponse } from "@/lib/mcp/responses"; +import { mcpAppsGateError } from "@/lib/mcp/tools/mcp-apps-gate"; /** * MCP Apps (SEP-1865) prototype: an embedded view of a Kernel browser @@ -546,6 +547,15 @@ export function registerLiveViewApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); + // Hidden from the model via visibility: ["app"], but hosts without MCP + // Apps support may ignore that hint. Fail closed like the other + // app-only tools so the model cannot poll screenshots directly. + const gateError = await mcpAppsGateError( + server, + extra.authInfo.token, + "This tool is only available to the embedded Kernel live view App on MCP Apps-capable hosts and cannot be called by the model. Use computer_action with a screenshot action instead.", + ); + if (gateError) return errorResponse(gateError); const client = createKernelClient(extra.authInfo.token); try { const screenshotResponse = diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 5c9b878..dd0727e 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -228,6 +228,17 @@ export async function waitForAuthConnection( const flowGuarded = selector.requiredFlowType !== undefined || selector.previousFlowExpiresAt !== undefined; + const flowFailed = + latest.flow_status === "FAILED" || + latest.flow_status === "EXPIRED" || + latest.flow_status === "CANCELED"; + // A terminal failure observed only after this wait saw the flow live + // is authoritative even when the connection still reads AUTHENTICATED + // (e.g. a failed re-auth keeps its previous session): the App shows + // the failure, so the wait must not report success. A terminal + // failure with no live flow observed predates this wait and leaves + // the authenticated state usable. + const observedFlowFailed = observedLiveFlow && flowFailed; const requiredFlowCompleted = !flowGuarded || (latest.flow_status === "SUCCESS" && @@ -241,15 +252,12 @@ export async function waitForAuthConnection( if ( latest.status === "AUTHENTICATED" && !hasLiveAuthFlow(latest) && + !observedFlowFailed && requiredFlowCompleted ) { return { state: "authenticated", connection: latest }; } - if ( - latest.flow_status === "FAILED" || - latest.flow_status === "EXPIRED" || - latest.flow_status === "CANCELED" - ) { + if (flowFailed) { // In flow-guarded mode a terminal flow matching the baseline predates // this wait (e.g. an old failed attempt); keep polling for the new // flow instead of reporting a stale failure. @@ -369,6 +377,20 @@ export function deriveAuthNextAction({ const connection = items[0]; if (connection.status === "AUTHENTICATED") { + if (hasLiveAuthFlow(connection)) { + return { + selection: { domain_filter, outcome: "single" }, + next_action: { + tool: "manage_auth_connections", + arguments: { + action: "wait", + id: connection.id, + }, + reason: + "This connection is authenticated but an authentication flow is still in progress. Wait for it to complete before creating a browser.", + }, + }; + } return { selection: { domain_filter, outcome: "single" }, next_action: { diff --git a/src/lib/mcp/tools/mcp-apps-gate.ts b/src/lib/mcp/tools/mcp-apps-gate.ts new file mode 100644 index 0000000..18eed5a --- /dev/null +++ b/src/lib/mcp/tools/mcp-apps-gate.ts @@ -0,0 +1,71 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { hasMcpAppsClient } from "@/lib/redis"; + +// MCP Apps (SEP-1865) extension identifier. Clients that render MCP Apps +// declare it in their initialize capabilities. +export const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui"; + +// Sliding TTL for the Redis capability marker. Long enough that an active App +// never loses it mid-flow; refreshed on every gated call. +const MCP_APPS_MARKER_TTL_SECONDS = 24 * 60 * 60; + +/** + * Whether a JSON-RPC payload (single message or batch) is an initialize that + * declares MCP Apps support. The route layer uses this to record the client + * capability, because the stateless streamable-HTTP transport does not expose + * it to later requests. + */ +export function initializeDeclaresMcpApps(body: unknown): boolean { + const messages = Array.isArray(body) ? body : [body]; + return messages.some((message) => { + if (!message || typeof message !== "object") return false; + const request = message as { + method?: unknown; + params?: { capabilities?: { extensions?: Record } }; + }; + return ( + request.method === "initialize" && + Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION]) + ); + }); +} + +/** + * App-only tools are hidden from the model via `_meta.ui.visibility`, but that + * is a hint hosts without MCP Apps support are free to ignore. Fail closed: + * only execute them when the connected client actually declared the MCP Apps + * extension. Persistent transports (SSE) expose client capabilities directly; + * on the stateless streamable-HTTP transport the route layer records the + * declared capability per bearer token at initialize time. + */ +export async function clientSupportsMcpApps( + server: McpServer, + authToken: string, +): Promise { + const capabilities = server.server.getClientCapabilities() as + | { extensions?: Record } + | undefined; + if (capabilities?.extensions?.[MCP_APPS_EXTENSION]) return true; + try { + return await hasMcpAppsClient({ + token: authToken, + ttlSeconds: MCP_APPS_MARKER_TTL_SECONDS, + }); + } catch (error) { + console.error("MCP Apps capability check failed; failing closed:", error); + return false; + } +} + +/** + * Returns null when the client may call app-only tools, otherwise a safe + * denial message to return as an error response. + */ +export async function mcpAppsGateError( + server: McpServer, + authToken: string, + deniedMessage: string, +): Promise { + if (await clientSupportsMcpApps(server, authToken)) return null; + return deniedMessage; +} From 7f5c6d2ae2c5cd8fbc6e42da40e60a1be8bf6c64 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 10:54:03 -0400 Subject: [PATCH 07/12] fix: close Bugbot round-3 findings on managed-auth MCP app - manage_auth_connections get: an AUTHENTICATED connection with a live in-progress flow no longer reports 'Authentication is verified'; the instruction now directs the model to wait first, matching waitForAuthConnection and deriveAuthNextAction semantics. - live view tools: session_id is now z.string().min(1) in both show_browser_live_view and capture_live_view_frame so empty identifiers fail validation with a clear error instead of an opaque SDK failure. Regression tests: handler-level get with a live re-auth flow, and schema validation for both live view tools. --- src/lib/mcp/tools/auth-connections.test.ts | 51 +++++++++++++++++++++- src/lib/mcp/tools/auth-connections.ts | 11 ++++- src/lib/mcp/tools/live-view-app.test.ts | 10 +++++ src/lib/mcp/tools/live-view-app.ts | 2 + 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index 1373581..fa592b6 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import type { KernelClient } from "@/lib/mcp/kernel-client"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAuthConnectionTools } from "@/lib/mcp/tools/auth-connections"; @@ -10,6 +10,21 @@ import { } from "@/lib/mcp/tools/managed-auth-state"; import type { ManagedAuth } from "@onkernel/sdk/resources/auth/connections"; +// Handler-level tests substitute a fake Kernel client; the default stub +// errors if any API method is actually invoked. +const unusedKernelClient = new Proxy( + {}, + { + get: () => { + throw new Error("unexpected Kernel client use"); + }, + }, +); +let kernelClientFactory: (token: string) => any = () => unusedKernelClient; +mock.module("@/lib/mcp/kernel-client", () => ({ + createKernelClient: (token: string) => kernelClientFactory(token), +})); + function connection(overrides: Partial = {}): ManagedAuth { return { id: "conn_1", @@ -137,6 +152,40 @@ describe("managed-auth safe responses", () => { expect(schema?.credential_path).toBeUndefined(); }); + test("get waits out a live in-progress flow on an authenticated connection", async () => { + let handler: ((params: any, extra: any) => Promise) | undefined; + const server = { + tool(...args: any[]) { + handler = args[args.length - 1]; + }, + } as unknown as McpServer; + registerAuthConnectionTools(server); + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => + connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + }, + }, + }); + try { + const result = await handler!( + { action: "get", id: "conn_1" }, + { authInfo: { token: "test-token" } }, + ); + expect(result.structuredContent.instruction).toContain("in progress"); + expect(result.structuredContent.instruction).not.toContain("verified"); + assertNoSecrets(result); + } finally { + kernelClientFactory = () => unusedKernelClient; + } + }); + test("allowlists fields and replaces raw errors", () => { const safe = toSafeAuthConnection( connection({ diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index ba878e3..469ddef 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -4,6 +4,7 @@ import { createKernelClient } from "@/lib/mcp/kernel-client"; import { AuthLoginStartError, deriveAuthNextAction, + hasLiveAuthFlow, toSafeAuthConnection, waitForAuthConnection, } from "@/lib/mcp/tools/managed-auth-state"; @@ -108,11 +109,17 @@ export function registerAuthConnectionTools(server: McpServer) { const connection = await client.auth.connections.retrieve( params.id, ); + const safe = toSafeAuthConnection(connection); return safeJsonResponse({ - connection: toSafeAuthConnection(connection), + connection: safe, instruction: connection.status === "AUTHENTICATED" - ? "Authentication is verified. Use this profile_name when creating the browser." + ? // Match waitForAuthConnection: a live in-progress flow means + // a (re-)auth is still running, so the current state is not + // settled yet. + hasLiveAuthFlow(safe) + ? "An authentication flow is still in progress for this connection. Call manage_auth_connections with action=wait and this id; create the browser only after it returns authenticated." + : "Authentication is verified. Use this profile_name when creating the browser." : "Do not continue the protected action. Ask for consent, then use open_auth_login to authenticate securely.", }); } diff --git a/src/lib/mcp/tools/live-view-app.test.ts b/src/lib/mcp/tools/live-view-app.test.ts index cd798dc..443f95f 100644 --- a/src/lib/mcp/tools/live-view-app.test.ts +++ b/src/lib/mcp/tools/live-view-app.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; import { registerLiveViewApp } from "@/lib/mcp/tools/live-view-app"; // Tests that exercise API-backed handlers substitute a fake Kernel client. @@ -78,6 +79,15 @@ function fakeScreenshotClient() { } describe("live view MCP App", () => { + test("live view tool schemas reject an empty session_id", () => { + const { tools } = captureRegistration(); + for (const name of ["show_browser_live_view", "capture_live_view_frame"]) { + const schema = z.object(tools.get(name)!.config.inputSchema); + expect(schema.safeParse({ session_id: "" }).success).toBe(false); + expect(schema.safeParse({ session_id: "sess_1" }).success).toBe(true); + } + }); + test("capture_live_view_frame fails closed on hosts without MCP Apps support", async () => { redisMarkerPresent = false; const { tools } = captureRegistration({ appsSupport: false }); diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts index 1584a05..0e7ad08 100644 --- a/src/lib/mcp/tools/live-view-app.ts +++ b/src/lib/mcp/tools/live-view-app.ts @@ -460,6 +460,7 @@ export function registerLiveViewApp(server: McpServer) { inputSchema: { session_id: z .string() + .min(1) .describe("Browser session ID to display (from manage_browsers)."), }, annotations: { @@ -531,6 +532,7 @@ export function registerLiveViewApp(server: McpServer) { inputSchema: { session_id: z .string() + .min(1) .describe("Browser session ID to capture a frame from."), }, annotations: { From 0df1c596b7bab812c1517ab4d80bcfa09ff06c09 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 11:00:59 -0400 Subject: [PATCH 08/12] fix: close Bugbot round-4 findings on managed-auth waits - waitForAuthConnection: an unguarded wait no longer fails fast on a terminal flow it never saw live. An old FAILED/EXPIRED/CANCELED flow on the connection (the common case before the user clicks Continue for a retry) predates the wait, so it keeps polling for the new flow; only a flow observed live by this wait (or one not matching the caller's baseline) reports failed. - hasLiveAuthFlow: an IN_PROGRESS flow with a missing or unparseable flow_expires_at is now treated as live, so a wait can no longer accept the stale pre-flow authenticated state while a re-auth may still be running. An expired expiry still means the flow deadline has passed. - open_auth_login/begin_auth_login: proxy_id and proxy_name are now z.string().min(1).optional() so empty identifiers fail validation clearly. Regression tests: unguarded wait polls past an old failed flow until the new flow succeeds, in-progress flow with unknown expiry reads as pending, safe-failure test now distinguishes observed-live failures from stale ones, and login schemas reject empty proxy identifiers. --- src/lib/mcp/tools/auth-connections.test.ts | 92 +++++++++++++++++++++- src/lib/mcp/tools/auth-login-app.test.ts | 17 ++++ src/lib/mcp/tools/auth-login-app.ts | 4 +- src/lib/mcp/tools/managed-auth-state.ts | 26 +++--- 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index fa592b6..d178fcd 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -559,8 +559,92 @@ describe("managed-auth wait", () => { expect(result.state).toBe("pending"); }); + test("unguarded wait polls past an old failed flow until the new flow succeeds", async () => { + // New-login path: the agent long-polls before the App starts a flow, so + // an old FAILED flow on the connection must not abort the wait. + const states = [ + connection({ + flow_status: "FAILED", + flow_expires_at: "2026-01-01T00:00:00Z", + }), + connection({ + flow_status: "IN_PROGRESS", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + ]; + let calls = 0; + const client = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: "conn_1" }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(result.state).toBe("authenticated"); + expect(calls).toBe(3); + }); + + test("treats an in-progress flow with unknown expiry as live", async () => { + // Missing flow_expires_at must not let a wait accept the stale pre-flow + // authenticated state while a re-auth may still be running. + const unknownExpiry = connection({ + status: "AUTHENTICATED", + flow_status: "IN_PROGRESS", + flow_type: "REAUTH", + flow_expires_at: undefined, + }); + const client = { + auth: { connections: { retrieve: async () => unknownExpiry } }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { connectionId: unknownExpiry.id }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + test("returns safe failure and pending states", async () => { + // A flow this wait saw live and then fail is reported as a safe failure. + const states = [ + connection({ + flow_status: "IN_PROGRESS", + flow_expires_at: "2099-01-01T00:00:00Z", + }), + connection({ + flow_status: "FAILED", + error_message: "raw site/API failure with secret", + }), + ]; + let calls = 0; const failedClient = { + auth: { + connections: { + retrieve: async () => states[Math.min(calls++, states.length - 1)], + }, + }, + } as unknown as KernelClient; + const failed = await waitForAuthConnection( + failedClient, + { connectionId: "conn_1" }, + { timeoutMs: 50, pollIntervalMs: 1 }, + ); + expect(failed.state).toBe("failed"); + assertNoSecrets(failed); + + // A terminal failure the wait never saw live predates it (e.g. an old + // failed attempt before the user clicked Continue): keep polling. + const staleClient = { auth: { connections: { retrieve: async () => @@ -571,13 +655,13 @@ describe("managed-auth wait", () => { }, }, } as unknown as KernelClient; - const failed = await waitForAuthConnection( - failedClient, + const stale = await waitForAuthConnection( + staleClient, { connectionId: "conn_1" }, { timeoutMs: 0 }, ); - expect(failed.state).toBe("failed"); - assertNoSecrets(failed); + expect(stale.state).toBe("pending"); + assertNoSecrets(stale); const pendingClient = { auth: { diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index b47f021..9868949 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -156,6 +156,23 @@ describe("managed-auth MCP App registration", () => { expect(statusSchema.safeParse({ connection_id: "" }).success).toBe(false); }); + test("login schemas reject empty proxy identifiers", () => { + const { tools } = captureRegistration(); + const base = { + mode: "new_login", + domain: "example.com", + profile_name: "work", + }; + for (const name of ["open_auth_login", "begin_auth_login"]) { + const schema = z.object(tools.get(name)!.config.inputSchema); + expect(schema.safeParse({ ...base, proxy_id: "" }).success).toBe(false); + expect(schema.safeParse({ ...base, proxy_name: "" }).success).toBe(false); + expect(schema.safeParse({ ...base, proxy_id: "proxy_1" }).success).toBe( + true, + ); + } + }); + test("normal launcher creates no backend flow or managed-auth handoff", async () => { const { tools } = captureRegistration(); const result = await tools.get("open_auth_login")!.handler( diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index d7063a6..e711523 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -106,8 +106,8 @@ const authLoginInputSchema = { domain: z.string().optional(), profile_name: z.string().optional(), save_credentials: z.boolean().optional(), - proxy_id: z.string().optional(), - proxy_name: z.string().optional(), + proxy_id: z.string().min(1).optional(), + proxy_name: z.string().min(1).optional(), }; function inputFromParams(params: AuthLoginInput): AuthLoginInput { diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index dd0727e..4296e6a 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -137,9 +137,14 @@ export function hasLiveAuthFlow( now = new Date(), ): boolean { if (connection.flow_status !== "IN_PROGRESS") return false; - if (!connection.flow_expires_at) return false; + if (!connection.flow_expires_at) { + // Expiry unknown: assume the flow is live rather than accepting a stale + // pre-flow state while a (re-)auth may still be running. + return true; + } const expiresAt = Date.parse(connection.flow_expires_at); - return Number.isFinite(expiresAt) && expiresAt > now.getTime(); + if (!Number.isFinite(expiresAt)) return true; + return expiresAt > now.getTime(); } async function findAuthConnection( @@ -258,15 +263,16 @@ export async function waitForAuthConnection( return { state: "authenticated", connection: latest }; } if (flowFailed) { - // In flow-guarded mode a terminal flow matching the baseline predates - // this wait (e.g. an old failed attempt); keep polling for the new - // flow instead of reporting a stale failure. - const terminalIsBaseline = - flowGuarded && + // A terminal flow this wait never saw live predates it: an old failed + // attempt (the common case when the user is about to click Continue + // for a retry), or one matching the caller's baseline. Keep polling + // for the new flow instead of reporting a stale failure. + const terminalIsStale = !observedLiveFlow && - selector.previousFlowExpiresAt !== undefined && - latest.flow_expires_at === selector.previousFlowExpiresAt; - if (!terminalIsBaseline) { + (selector.previousFlowExpiresAt !== undefined + ? latest.flow_expires_at === selector.previousFlowExpiresAt + : !flowGuarded); + if (!terminalIsStale) { return { state: "failed", connection: latest }; } } From 2a40830c10ce336825cca31b7254032757163e3b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 11:07:35 -0400 Subject: [PATCH 09/12] fix: close Bugbot round-5 findings on marker identity and relay JWTs - MCP Apps capability markers are now keyed by the OAuth session id (sid) instead of the raw bearer token, so access-token refresh no longer strands the marker on the old token hash and app-only tools keep working after a refresh. Static API keys and sid-less JWTs fall back to the token hash. The JWT payload is decoded, not verified, which is safe: markers are recorded only after the route layer verifies the token and gated calls only ever see verified tokens. Recording now uses the long sliding TTL since the session outlives individual access tokens. - managed-auth relay: scoped bearer JWTs with an exp in the past are rejected with 401 at the relay boundary instead of being forwarded upstream. Regression tests: mcpAppsMarkerSubject refresh stability, session separation, and token-hash fallback; relay rejects an expired scoped JWT. --- src/app/[transport]/route.ts | 11 ++--- .../auth/connections/[...path]/route.test.ts | 16 +++++++ .../auth/connections/[...path]/route.ts | 5 ++- src/lib/mcp-apps-marker.test.ts | 45 +++++++++++++++++++ src/lib/mcp-apps-marker.ts | 39 ++++++++++++++++ src/lib/redis.ts | 13 ++---- 6 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 src/lib/mcp-apps-marker.test.ts create mode 100644 src/lib/mcp-apps-marker.ts diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index b97d764..a8a837f 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -114,13 +114,10 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { ); } - // Bind the capability marker to this token's own lifetime: when the token - // dies the client must re-authenticate (and re-initialize) anyway. - const tokenTtlSeconds = - typeof payload.exp === "number" - ? Math.max(60, payload.exp - Math.floor(Date.now() / 1000)) - : 60 * 60; - await recordMcpAppsCapability(req, token, tokenTtlSeconds); + // The marker is keyed by the session (sid), which survives access-token + // refresh, so bind it to the long sliding TTL (refreshed on every gated + // app-only call) rather than this one token's lifetime. + await recordMcpAppsCapability(req, token, 24 * 60 * 60); // Create authenticated handler with auth info const authHandler = withMcpAuth( 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 index a819264..06ebe97 100644 --- a/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts @@ -72,6 +72,22 @@ describe("managed-auth relay", () => { 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) => { diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts index 2fde2da..7324d5e 100644 --- a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts @@ -57,7 +57,10 @@ function managedAuthAuthorization(request: Request): string | null { claims?.iss !== "kernel-api" || typeof claims.managed_auth_session_id !== "string" || !claims.managed_auth_session_id || - typeof claims.exp !== "number" + typeof claims.exp !== "number" || + // Reject expired session JWTs at the relay boundary instead of + // forwarding them upstream. + claims.exp * 1000 <= Date.now() ) { return null; } diff --git a/src/lib/mcp-apps-marker.test.ts b/src/lib/mcp-apps-marker.test.ts new file mode 100644 index 0000000..0e3782b --- /dev/null +++ b/src/lib/mcp-apps-marker.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { mcpAppsMarkerSubject } from "@/lib/mcp-apps-marker"; + +process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +describe("MCP Apps marker subject", () => { + test("survives OAuth access-token refresh via the session id", () => { + const first = jwt({ sub: "user_1", sid: "sess_1", exp: 100 }); + const refreshed = jwt({ + sub: "user_1", + sid: "sess_1", + exp: 200, + iat: 150, + }); + expect(mcpAppsMarkerSubject(first)).toBe("sid:sess_1"); + expect(mcpAppsMarkerSubject(refreshed)).toBe("sid:sess_1"); + }); + + test("keys distinct sessions apart", () => { + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).toBe("sid:sess_2"); + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).not.toBe( + mcpAppsMarkerSubject(jwt({ sid: "sess_3" })), + ); + }); + + test("falls back to the token hash for API keys and sid-less JWTs", () => { + const apiKey = mcpAppsMarkerSubject("sk_test_key"); + expect(apiKey.startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject("sk_test_key")).toBe(apiKey); + expect(mcpAppsMarkerSubject("sk_other_key")).not.toBe(apiKey); + + const sidlessA = jwt({ sub: "user_1", exp: 100 }); + const sidlessB = jwt({ sub: "user_1", exp: 200 }); + expect(mcpAppsMarkerSubject(sidlessA).startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject(sidlessA)).not.toBe( + mcpAppsMarkerSubject(sidlessB), + ); + }); +}); diff --git a/src/lib/mcp-apps-marker.ts b/src/lib/mcp-apps-marker.ts new file mode 100644 index 0000000..6882361 --- /dev/null +++ b/src/lib/mcp-apps-marker.ts @@ -0,0 +1,39 @@ +import { createHmac } from "crypto"; + +function hashBearerToken(token: string): string { + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return createHmac("sha256", secretKey).update(token).digest("hex"); +} + +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( + Buffer.from(normalized + padding, "base64").toString("utf8"), + ) as Record; + } catch { + return null; + } +} + +/** + * Stable identity for a client's MCP Apps capability marker. OAuth access + * tokens rotate on refresh while the session (sid) survives, so key JWT + * sessions by sid; anything else (static API keys, sid-less JWTs) falls back + * to the token hash. The payload is only decoded, not verified: markers are + * recorded solely after the route layer verified the token, and gated calls + * only ever see verified tokens, so a sid here always belongs to the caller. + */ +export function mcpAppsMarkerSubject(token: string): string { + const claims = decodeJwtPayload(token); + if (claims && typeof claims.sid === "string" && claims.sid) { + return `sid:${claims.sid}`; + } + return `token:${hashBearerToken(token)}`; +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 9f6f4ac..758b1c3 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -1,5 +1,6 @@ import { createClient } from "redis"; import { createHmac } from "crypto"; +import { mcpAppsMarkerSubject } from "@/lib/mcp-apps-marker"; const redisUrl = process.env.REDIS_URL; const redisTlsServerName = process.env.REDIS_TLS_SERVER_NAME; @@ -156,14 +157,6 @@ export { client as redisClient }; // never declared MCP Apps support. const MCP_APPS_KEY_PREFIX = "mcp-apps:"; -function hashBearerToken(token: string): string { - const secretKey = process.env.CLERK_SECRET_KEY; - if (!secretKey) { - throw new Error("CLERK_SECRET_KEY environment variable must be set"); - } - return createHmac("sha256", secretKey).update(token).digest("hex"); -} - export async function markMcpAppsClient({ token, ttlSeconds, @@ -172,7 +165,7 @@ export async function markMcpAppsClient({ ttlSeconds: number; }): Promise { await ensureConnected(); - const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`; + const key = `${MCP_APPS_KEY_PREFIX}${mcpAppsMarkerSubject(token)}`; await withReconnect(() => client.setEx(key, Math.max(60, Math.floor(ttlSeconds)), "1"), ); @@ -190,7 +183,7 @@ export async function hasMcpAppsClient({ ttlSeconds: number; }): Promise { await ensureConnected(); - const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`; + const key = `${MCP_APPS_KEY_PREFIX}${mcpAppsMarkerSubject(token)}`; const value = await withReconnect(() => client.get(key)); if (value === null) return false; await withReconnect(() => From b67c10526601381eae3bf45e00822b7916789722 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 16:16:56 -0400 Subject: [PATCH 10/12] chore: scope PR to managed auth MCP app --- .env.example | 4 +- AGENTS.md | 14 +- README.md | 5 +- docs/mcp-apps-prototype.md | 240 ---------- scripts/mcp-apps-proxy.ts | 434 ----------------- scripts/qa-harness-test.mjs | 135 ------ src/lib/mcp/register.ts | 3 - src/lib/mcp/tools/auth-login-app.test.ts | 92 +--- src/lib/mcp/tools/auth-login-app.ts | 121 ----- src/lib/mcp/tools/browsers.ts | 5 +- src/lib/mcp/tools/live-view-app.test.ts | 137 ------ src/lib/mcp/tools/live-view-app.ts | 581 ----------------------- src/lib/mcp/tools/playwright.ts | 2 +- 13 files changed, 33 insertions(+), 1740 deletions(-) delete mode 100644 docs/mcp-apps-prototype.md delete mode 100644 scripts/mcp-apps-proxy.ts delete mode 100644 scripts/qa-harness-test.mjs delete mode 100644 src/lib/mcp/tools/live-view-app.test.ts delete mode 100644 src/lib/mcp/tools/live-view-app.ts diff --git a/.env.example b/.env.example index 1f691bc..c102632 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev API_BASE_URL= # Public origin of this MCP server. The Managed Auth MCP App connects only to -# the narrowly scoped same-origin relay. Local QA: http://localhost:3002 +# 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 @@ -23,7 +23,7 @@ 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, auth_connections, browser_pools, browsers, computer, docs, extensions, live_view_app, 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/AGENTS.md b/AGENTS.md index b34534b..65c6828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,10 +141,10 @@ The `KERNEL_API_KEY` env var is passed as the Bearer token and forwarded to the ### Services overview -| Service | Required | Notes | -| ------------------------------ | -------- | ----------------------------------------------------- | -| Next.js dev server (port 3002) | Yes | `make dev` — the MCP server itself | -| Clerk (SaaS) | Yes | OAuth 2.1 auth — config injected via `op run` | -| Redis | Yes | OAuth org-context storage — URL injected via `op run` | -| Kernel Platform API (SaaS) | Yes | Backend for all MCP tools — reached via SDK | -| Mintlify (SaaS) | Optional | Only for `search_docs` tool | +| Service | Required | Notes | +|---------|----------|-------| +| Next.js dev server (port 3002) | Yes | `make dev` — the MCP server itself | +| Clerk (SaaS) | Yes | OAuth 2.1 auth — config injected via `op run` | +| Redis | Yes | OAuth org-context storage — URL injected via `op run` | +| Kernel Platform API (SaaS) | Yes | Backend for all MCP tools — reached via SDK | +| Mintlify (SaaS) | Optional | Only for `search_docs` tool | diff --git a/README.md b/README.md index 48b43c2..e27fcbd 100644 --- a/README.md +++ b/README.md @@ -255,11 +255,11 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (19 model-facing, plus 4 app-only helpers) +## Tools (17 model-facing, plus 2 app-only helpers) 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. -Four additional helper tools (`begin_auth_login`, `get_auth_login_status`, `delete_auth_login_connection`, `capture_live_view_frame`) are marked app-only (`_meta.ui.visibility: ["app"]`) and exist solely for the embedded MCP Apps views; they refuse to execute on hosts that do not declare MCP Apps support. +Two additional Managed Auth helpers (`begin_auth_login` and `get_auth_login_status`) are marked app-only (`_meta.ui.visibility: ["app"]`); they refuse to execute on hosts that do not declare MCP Apps support. 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. @@ -286,7 +286,6 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `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. Credentials and MFA never enter MCP/model traffic. -- `show_browser_live_view` - Render an embedded, read-only live view of a browser session inline in the chat (auto-refreshing snapshots) on MCP Apps-capable hosts; returns the live view URLs as text elsewhere. ## Resources diff --git a/docs/mcp-apps-prototype.md b/docs/mcp-apps-prototype.md deleted file mode 100644 index 9a35eb5..0000000 --- a/docs/mcp-apps-prototype.md +++ /dev/null @@ -1,240 +0,0 @@ -# MCP Apps: browser live view and managed authentication - -## Managed Auth App - -The managed-auth flow is discovery-first and fail-closed: - -```text -manage_auth_connections(action="list", domain_filter=...) - -> ask the user to choose/consent - -> open_auth_login(...) - -> agent immediately starts manage_auth_connections(action="wait") - -> user clicks Continue in the App - -> begin_auth_login (app-only) - -> bundled KernelManagedAuth uses the scoped relay - -> App publishes verified status with ui/update-model-context - -> read-only wait returns authenticated - -> agent continues the pending task automatically -``` - -`open_auth_login` does not create a connection or flow until the user clicks Continue. The -single-file `ui://kernel/managed-auth-login-v7.html` resource bundles -`@onkernel/managed-auth-react`; it never iframes the hosted page. Passwords, MFA values, -managed-auth JWTs, and the Kernel/MCP bearer token never pass through tool calls. The handoff -code and hosted fallback URL exist only in the app-only `begin_auth_login` result (`_meta`, -duplicated into `structuredContent.app_private` because some hosts strip tool-result `_meta`). -App-only tools fail closed: `begin_auth_login`, `get_auth_login_status`, and -`delete_auth_login_connection` execute only when the connected client declared the -`io.modelcontextprotocol/ui` extension during initialize, so on hosts without MCP Apps -support the model cannot call them to bypass user consent or pull App-private material into -its context. The delete-authorizing `app_capability` is likewise issued only to -MCP Apps-capable clients and travels only in the launcher result `_meta`. -Because the streamable-HTTP transport is stateless (one `McpServer` per request), the route -layer (`src/app/[transport]/route.ts`) observes each `initialize` body and records the -declared capability per bearer token in Redis (sliding TTL, bound to the token lifetime for -OAuth tokens); persistent SSE transports are checked directly through the SDK server. -The component sends credential input directly to `/managed-auth-proxy`, which accepts only -exchange, retrieve, submit, and events paths and forwards only managed-auth scoped JWTs. - -The bundled App resource is byte-checked in CI (`bun run check:managed-auth-app`) and Bun's -minifier output can change between releases, so CI pins Bun (see `.github/workflows/ci.yml`). -Regenerate the bundle with that exact Bun version after editing -`src/lib/mcp/apps/managed-auth-entry.tsx`: `bun run build:managed-auth-app`. - -For clients without Apps, first confirm that no panel appeared, then call `open_auth_login` -with `text_only: true`. This explicit compatibility exception places the full hosted URL -(including its embedded handoff capability) in user-audience text. It never emits a separate -`handoff_code`, and the agent must run the same read-only wait instead of treating URL creation -as successful authentication. The wait is guarded by the pre-flow `previous_flow_expires_at` -baseline captured at begin time, and a connection that is `AUTHENTICATED` while a flow is -still `IN_PROGRESS` reads as pending — stale pre-flow state is never accepted as the result -of a re-auth. - -Local QA: - -```bash -MANAGED_AUTH_APP_ORIGIN=http://localhost:3002 bun run dev -KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts -# Open http://localhost:3003/qa/auth, or: -bun scripts/qa-harness-test.mjs --auth -``` - -The QA host intentionally logs only app tool names, never app-private tool results or hosted -URLs. Delete the test connection with the harness cleanup button. - -## Embedded browser live view - -Prototype of [MCP Apps (SEP-1865)](https://apps.extensions.modelcontextprotocol.io/) in the -Kernel MCP server: when an agent spins up a Kernel browser, it can render an **embedded, -read-only view** of that browser inline in the chat (auto-refreshing snapshots), so the user -watches the automation happen in real time. The full interactive live view is one click away -via `ui/open-link`. - -## What was added - -| File | Purpose | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/lib/mcp/tools/live-view-app.ts` | The MCP App: a `ui://kernel/live-view.html` resource (`text/html;profile=mcp-app`) + a `show_browser_live_view` tool linked to it via `_meta.ui.resourceUri`. The view speaks raw postMessage JSON-RPC per the spec and renders read-only snapshots by polling the app-only `capture_live_view_frame` tool through the host. | -| `src/lib/mcp/register.ts` | Registers the new `live_view_app` toolset. | -| `src/lib/mcp/tools/browsers.ts` | `manage_browsers` create/update responses now hint the agent to call `show_browser_live_view` right after creating a session. | -| `scripts/mcp-apps-proxy.ts` | Dev-only reverse proxy (port 3003) that injects `Authorization: Bearer $KERNEL_API_KEY` so Claude's custom connectors (which can't send static bearer tokens) can use the server as a "no auth" connector. It also 404s the server's OAuth surface (`/.well-known/*`, `/register`, `/authorize`, `/token`) — otherwise Claude discovers it and attempts dynamic client registration against the dummy Clerk config, failing with "Couldn't register with … sign-in service". Also serves a `/qa` harness page. | -| `scripts/qa-harness-test.mjs` | Playwright smoke test that drives the `/qa` harness end to end. | - -How it works (per the spec): - -1. `tools/list` exposes `show_browser_live_view` with `_meta.ui.resourceUri: "ui://kernel/live-view.html"`. -2. When the agent calls the tool, an MCP Apps-capable host fetches the resource via - `resources/read` and renders the HTML in a sandboxed iframe. -3. The view does the `ui/initialize` → `ui/notifications/initialized` handshake and receives - the tool result (`session_id` + live view URLs) via `ui/notifications/tool-result`. -4. The view then polls the app-only `capture_live_view_frame` tool through the host - (`tools/call` over postMessage) every ~2.5s and swaps the returned PNG frames into an - `` as `data:` URIs. -5. **No nested iframe, no external origins**: the spec's restrictive default CSP is enough. - We deliberately don't iframe the real live view — Claude blocks third-party `frameDomains` - pending security review, and snapshots work identically in every host. -6. Hosts without MCP Apps support just see the URLs as text — graceful fallback. - -## Running the stack locally - -Three processes: - -```bash -# 1. The MCP server (port 3002). .env.local only needs format-valid dummy Clerk -# values — the API-key auth path never touches Clerk/Redis. -bun run dev - -# 2. The auth-injecting proxy (port 3003 -> 3002) -KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts - -# 3. The tunnel (points at the PROXY, not the server) -ngrok http --url=raf-kernel-mcp-server.ngrok.app 3003 -``` - -MCP endpoint: `https://raf-kernel-mcp-server.ngrok.app/mcp` - -> ⚠️ **Security**: anyone with the tunnel URL gets your Kernel account (the proxy injects your -> API key on every request). Tear the tunnel down when you're done. Never point the proxy at a -> production deployment. - -## Adding to Claude (Desktop or claude.ai) - -Requires a plan with custom connectors (Pro/Max/Team) and a recent Claude version with MCP -Apps support. - -1. **Settings → Connectors → Add custom connector** -2. Name: `Kernel (dev)`, URL: `https://raf-kernel-mcp-server.ngrok.app/mcp` → **Add**. - No OAuth/Connect prompt should appear (the proxy handles auth and hides the OAuth - discovery endpoints); the connector should be immediately usable. -3. In a chat, open **Search and tools** and make sure the Kernel tools are enabled. - -If you previously added the connector and got _"Couldn't register with … sign-in service"_, -**remove the connector and re-add it** — Claude caches the discovered OAuth metadata from -the first attempt. - -## QA script (Claude) - -1. Prompt: _"Create a Kernel browser and show me its live view, then go to - news.ycombinator.com and tell me the top story."_ -2. Expect the agent to call `manage_browsers` (create) → `show_browser_live_view` → the app - renders inline: header "Kernel browser" with a green **READ-ONLY** badge, then browser - snapshots appear within a few seconds and refresh every ~2.5s. -3. While the agent drives the browser (`execute_playwright_code` / `computer_action`), watch - navigation happen live in the embedded view. -4. Read-only check: click/type inside the embedded view — the page should not react. -5. Click **Open interactive view ↗** — the full interactive live view should open in your - browser (host shows a link confirmation first). -6. Say _"delete the browser"_ — after deletion the embed goes dark/disconnects (expected; - the stream ended). - -Failure modes to watch for: - -- App doesn't render, tool result shows as plain text → host lacks MCP Apps support (old - Claude version, or apps not enabled for connectors). -- App renders but frames never appear → the host is not proxying app-initiated `tools/call` - (check the debug line for "snapshot failed" and the proxy log for - `rpc tools/call name=capture_live_view_frame`). -- "Waiting for browser live view…" forever → tool result never reached the view; check the - host delivered `ui/notifications/tool-result` (host devtools) and that the session wasn't - headless (headless sessions have no live view). - -## QA without Claude (any browser) - -The proxy serves a minimal MCP Apps host at: - -``` -https://raf-kernel-mcp-server.ngrok.app/qa (or http://localhost:3003/qa) -``` - -Click **1. Create browser & render app** (creates a real browser + renders the real -`ui://` resource and drives the postMessage lifecycle), **2. Navigate** to watch the stream -update live, **3. Delete browser** to clean up. If ngrok shows its interstitial page first, -click "Visit Site". - -Automated version: `bun scripts/qa-harness-test.mjs` (screenshots in `/tmp/qa-*.png`). - -Protocol-level checks with curl (API key as bearer): - -```bash -# tool metadata carries the ui resource link -curl -s -X POST http://localhost:3002/mcp \ - -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "Authorization: Bearer $KERNEL_API_KEY" \ - -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' -# -> show_browser_live_view has _meta.ui.resourceUri = ui://kernel/live-view.html - -# the UI resource itself -curl -s -X POST http://localhost:3002/mcp \ - -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "Authorization: Bearer $KERNEL_API_KEY" \ - -d '{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"ui://kernel/live-view.html"}}' -# -> mimeType text/html;profile=mcp-app, _meta.ui.csp.frameDomains for *.onkernel.com -``` - -## Claude-specific findings (from QA) - -- **Claude blocks nested third-party iframes entirely.** Per Claude's MCP Apps design - guidelines: _"`frameDomains` (embedding third-party iframes) is currently restricted in - Claude pending security review."_ An earlier iteration of this prototype iframed the real - live view and hit `frame-src` CSP violations in Claude; that's why the app is now - **snapshot-only** (polling `capture_live_view_frame`, visibility `["app"]`). If Kernel ever - wants the true WebRTC stream inline, revisit once Claude ships frameDomains support. -- **Custom remote connectors in Claude may connect via a runtime that doesn't support MCP - Apps** (`client=claude-code`, no `io.modelcontextprotocol/ui` extension — renders a blank - box). The reliable path is a **local stdio server** in `claude_desktop_config.json` bridged - with `mcp-remote`: - - ```json - { - "mcpServers": { - "kernel-dev": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://raf-kernel-mcp-server.ngrok.app/mcp" - ] - } - } - } - ``` - -- Claude's getting-started docs list example packages as `@modelcontextprotocol/-server`; - the real npm names are `@modelcontextprotocol/server-` (e.g. `server-shadertoy`). -- Debugging: Claude Desktop → Help → Troubleshooting → Enable Developer Mode, then - `Cmd+Option+I`; the app is the inner of two nested iframes. The view also prints lifecycle - state to a debug line at the bottom of the widget. - -## Known limitations / next steps - -- **Capability negotiation**: `mcp-handler` creates a fresh MCP server for every Streamable - HTTP POST, so initialize capabilities are unavailable when a later `tools/list` request is - handled. App-only helpers therefore use the MCP Apps `visibility: ["app"]` contract rather - than server-side capability filtering; they also reject calls without a short-lived, - bearer-bound capability delivered only in the launcher's App-private `_meta`. -- The `manage_browsers` tool itself could carry the UI metadata so the app renders on create - without a second tool call; kept separate to limit blast radius. -- Production auth: this prototype's proxy is dev-only. Production would use the existing - OAuth flow (mcp.onkernel.com) — no proxy needed since Claude does OAuth there. -- The live view URL expires with the session; the view could poll session state via an - app-visible tool (`visibility: ["app"]`) and show a "session ended" state. diff --git a/scripts/mcp-apps-proxy.ts b/scripts/mcp-apps-proxy.ts deleted file mode 100644 index 7c8e3f0..0000000 --- a/scripts/mcp-apps-proxy.ts +++ /dev/null @@ -1,434 +0,0 @@ -/** - * Dev-only reverse proxy that injects a Kernel API key as the Authorization - * header in front of the local MCP server. - * - * Why: Claude Desktop / claude.ai custom connectors can only do OAuth or - * no-auth — they cannot send a static bearer token. The kernel-mcp-server - * accepts non-JWT bearer tokens as Kernel API keys, so this proxy makes the - * server usable as a "no auth" connector while still authenticating every - * request upstream with your API key. - * - * KERNEL_API_KEY=sk_... bun scripts/mcp-apps-proxy.ts - * - * Then point ngrok at this proxy (default port 3003), NOT at the Next server: - * - * ngrok http --url=raf-kernel-mcp-server.ngrok.app 3003 - * - * DO NOT run this against a production deployment or leave the tunnel up - * unattended: anyone with the ngrok URL can use your Kernel account. - */ - -// Bun provides this global at runtime; declared here so the repo's tsconfig -// (which has no Bun types) still typechecks. -declare const Bun: { serve(options: unknown): unknown }; - -const UPSTREAM = process.env.MCP_UPSTREAM ?? "http://localhost:3002"; -const UPSTREAM_URL = new URL(UPSTREAM); -if (!["http:", "https:"].includes(UPSTREAM_URL.protocol)) { - throw new Error("MCP_UPSTREAM must use http or https"); -} -const PORT = Number(process.env.PORT ?? 3003); -const API_KEY = process.env.KERNEL_API_KEY; - -if (!API_KEY) { - console.error("KERNEL_API_KEY is required"); - process.exit(1); -} - -const HOP_BY_HOP = new Set([ - "host", - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "content-length", -]); - -function filterHeaders(source: Headers): Headers { - const out = new Headers(); - source.forEach((value, key) => { - if (!HOP_BY_HOP.has(key.toLowerCase())) out.set(key, value); - }); - return out; -} - -// Minimal MCP Apps "host" page for QA-ing the live view app in any browser -// (no MCP Apps-capable client needed). It drives the real MCP server through -// this proxy: initialize -> create browser -> show_browser_live_view -> -// resources/read -> render the view in an iframe and speak host-side -// postMessage JSON-RPC to it. -const QA_HARNESS_HTML = /* html */ ` - - - -Kernel MCP Apps QA Harness - - - -

Kernel MCP Apps QA harness \u2014 embedded live view

-

Simulates an MCP Apps host: creates a real Kernel browser, calls show_browser_live_view, renders the ui://kernel/live-view-v2.html resource below, delivers the tool result over postMessage, and proxies the app's capture_live_view_frame snapshot polling.

- - - -
- - - - -`; - -const AUTH_QA_HARNESS_HTML = /* html */ ` - - - -Kernel Managed Auth MCP App QA - - - -

Managed Auth MCP App QA

-

The flow is not created until Continue is clicked inside the embedded App.

- - - - -
- - - -`; - -Bun.serve({ - port: PORT, - idleTimeout: 240, - async fetch(req: Request) { - const url = new URL(req.url); - - if (req.method === "GET" && url.pathname === "/qa") { - return new Response(QA_HARNESS_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - } - if (req.method === "GET" && url.pathname === "/qa/auth") { - return new Response(AUTH_QA_HARNESS_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - } - - // Hide the server's OAuth surface. This proxy injects an API key on every - // request, so from the outside this must look like a no-auth MCP server. - // If OAuth discovery leaks through, clients like Claude attempt dynamic - // client registration against the (dummy-configured) Clerk instance and - // the connector fails to connect. - if ( - url.pathname.startsWith("/.well-known/") || - url.pathname === "/register" || - url.pathname === "/authorize" || - url.pathname === "/token" - ) { - console.log( - `[proxy] ${req.method} ${url.pathname} -> 404 (oauth surface hidden)`, - ); - return new Response("Not found", { status: 404 }); - } - - // Assign onto a fixed parsed origin. Constructing from a string beginning - // with `//` would reinterpret an attacker-controlled path as a new host and - // exfiltrate the API key attached below. - const target = new URL(UPSTREAM_URL); - target.pathname = url.pathname; - target.search = url.search; - - const headers = filterHeaders(req.headers); - // Only the MCP endpoint needs the development API-key injection. Managed - // Auth relay requests carry their own short-lived, session-scoped JWT; - // overwriting it would break retrieve/submit after handoff exchange. - if (url.pathname === "/mcp") { - headers.set("authorization", `Bearer ${API_KEY}`); - } - - // Decode JSON-RPC traffic for debugging (bodies are small JSON payloads). - let body: BodyInit | null = req.body; - if (req.method === "POST" && url.pathname === "/mcp") { - const bodyText = await req.text(); - body = bodyText; - try { - const parsed = JSON.parse(bodyText); - const messages = Array.isArray(parsed) ? parsed : [parsed]; - for (const m of messages) { - if (!m || typeof m.method !== "string") continue; - let detail = m.method; - if (m.method === "tools/call") detail += ` name=${m.params?.name}`; - if (m.method === "resources/read") detail += ` uri=${m.params?.uri}`; - if (m.method === "initialize") { - const client = m.params?.clientInfo; - detail += ` client=${client?.name}@${client?.version} protocol=${m.params?.protocolVersion ?? "unknown"} tasks=${JSON.stringify( - m.params?.capabilities?.tasks ?? null, - )} extensions=${JSON.stringify( - m.params?.capabilities?.extensions ?? null, - )}`; - } - console.log(`[proxy] rpc ${detail}`); - } - } catch { - /* non-JSON body; forward as-is */ - } - } - - let upstream: Response; - try { - upstream = await fetch(target, { - method: req.method, - headers, - body, - redirect: "manual", - // @ts-expect-error half-duplex streaming request bodies - duplex: "half", - }); - } catch (error) { - console.error( - `[proxy] ${req.method} ${url.pathname} -> upstream error`, - error, - ); - return new Response("Upstream unavailable", { status: 502 }); - } - - console.log(`[proxy] ${req.method} ${url.pathname} -> ${upstream.status}`); - return new Response(upstream.body, { - status: upstream.status, - headers: filterHeaders(upstream.headers), - }); - }, -}); - -console.log( - `mcp-apps auth proxy listening on http://localhost:${PORT} -> ${UPSTREAM} (injecting Kernel API key)`, -); diff --git a/scripts/qa-harness-test.mjs b/scripts/qa-harness-test.mjs deleted file mode 100644 index 2a409c5..0000000 --- a/scripts/qa-harness-test.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import { chromium } from "playwright"; - -const authMode = process.argv.includes("--auth"); -const qaBaseUrl = process.env.QA_BASE_URL ?? "http://localhost:3003"; -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1000, height: 1500 } }); -page.on("pageerror", (error) => console.log("[pageerror]", error.message)); -const logText = () => page.locator("#log").textContent(); - -if (authMode) { - let failure; - try { - await page.goto(`${qaBaseUrl}/qa/auth`); - await page.fill("#profile", `mcp-auth-qa-${Date.now()}`); - await page.click("#start"); - await page.waitForFunction( - () => { - const text = document.getElementById("log").textContent; - return ( - text.includes("Secure App resource rendered") || - text.includes("ERROR") - ); - }, - null, - { timeout: 60000 }, - ); - if ((await logText()).includes("ERROR")) { - throw new Error(`Auth QA launcher failed:\n${await logText()}`); - } - - const exchangeResponse = page.waitForResponse( - (response) => - new URL(response.url()).pathname.endsWith("/exchange") && - response.request().method() === "POST", - { timeout: 120000 }, - ); - const retrieveResponse = page.waitForResponse( - (response) => - /^\/managed-auth-proxy\/auth\/connections\/[^/]+$/.test( - new URL(response.url()).pathname, - ) && response.request().method() === "GET", - { timeout: 120000 }, - ); - - const app = page.frameLocator("#view"); - await app.getByRole("button", { name: /continue/i }).click(); - await page.waitForFunction( - () => { - const text = document.getElementById("log").textContent; - return ( - text.includes("Managed-auth connection ready") || - text.includes("ERROR") - ); - }, - null, - { timeout: 120000 }, - ); - if ((await logText()).includes("ERROR")) { - throw new Error(`Auth QA begin failed:\n${await logText()}`); - } - - const [exchange, retrieve] = await Promise.all([ - exchangeResponse, - retrieveResponse, - ]); - if (!exchange.ok() || !retrieve.ok()) { - throw new Error( - `Managed-auth relay failed: exchange=${exchange.status()} retrieve=${retrieve.status()}`, - ); - } - await page.screenshot({ - path: "/tmp/qa-managed-auth-app.png", - fullPage: true, - }); - console.log( - `--- managed-auth QA ---\n${await logText()}relay exchange=${exchange.status()} retrieve=${retrieve.status()}\n`, - ); - } catch (error) { - failure = error; - } finally { - if ( - await page - .locator("#cleanup") - .isEnabled() - .catch(() => false) - ) { - await page.click("#cleanup"); - await page.waitForFunction( - () => document.getElementById("log").textContent.includes("deleted"), - null, - { timeout: 60000 }, - ); - } - } - if (failure) throw failure; -} else { - await page.goto(`${qaBaseUrl}/qa`); - await page.click("#start"); - await page.waitForFunction( - () => { - const text = document.getElementById("log").textContent; - return text.includes("view rendered") || text.includes("ERROR"); - }, - null, - { timeout: 180000 }, - ); - console.log("--- after start ---\n" + (await logText())); - - await page.waitForTimeout(10000); - await page.screenshot({ path: "/tmp/qa-1-rendered.png", fullPage: true }); - - await page.click("#navigate"); - await page.waitForFunction( - () => { - const text = document.getElementById("log").textContent; - return text.includes("navigated") || text.split("ERROR").length > 1; - }, - null, - { timeout: 120000 }, - ); - await page.waitForTimeout(5000); - await page.screenshot({ path: "/tmp/qa-2-navigated.png", fullPage: true }); - console.log("--- after navigate ---\n" + (await logText())); - - await page.click("#cleanup"); - await page.waitForFunction( - () => - document.getElementById("log").textContent.includes("browser deleted"), - null, - { timeout: 60000 }, - ); - console.log("--- after cleanup ---\n" + (await logText())); -} - -await browser.close(); diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 6600551..04a5b39 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -12,7 +12,6 @@ import { registerCredentialProviderTools } from "@/lib/mcp/tools/credential-prov import { registerCredentialTools } from "@/lib/mcp/tools/credentials"; import { registerDocsTools } from "@/lib/mcp/tools/docs"; import { registerExtensionTools } from "@/lib/mcp/tools/extensions"; -import { registerLiveViewApp } from "@/lib/mcp/tools/live-view-app"; import { registerPlaywrightTool } from "@/lib/mcp/tools/playwright"; import { registerProfileCapabilities } from "@/lib/mcp/tools/profiles"; import { registerProjectCapabilities } from "@/lib/mcp/tools/projects"; @@ -31,7 +30,6 @@ const mcpToolRegistrations = [ ["profiles", registerProfileCapabilities], ["docs", registerDocsTools], ["browsers", registerBrowserCapabilities], - ["live_view_app", registerLiveViewApp], ["projects", registerProjectCapabilities], ["api_keys", registerAPIKeyCapabilities], ["browser_pools", registerBrowserPoolCapabilities], @@ -59,7 +57,6 @@ const standaloneToolsetAliases: Partial> = { execute_playwright_code: "playwright", exec_command: "shell", browser_utilities: "browser_curl", - show_browser_live_view: "live_view_app", open_auth_login: "auth_connections", }; diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 9868949..e523287 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -95,8 +95,6 @@ function captureRegistration({ appsSupport = true } = {}) { } describe("managed-auth MCP App registration", () => { - process.env.MCP_APP_SIGNING_SECRET = "test-app-signing-secret"; - test("uses exact resource MIME, URI, and CSP on registration and read", async () => { process.env.MANAGED_AUTH_APP_ORIGIN = "http://localhost:3002"; const captured = captureRegistration(); @@ -130,26 +128,11 @@ describe("managed-auth MCP App registration", () => { expect( tools.get("get_auth_login_status")?.config._meta.ui.visibility, ).toEqual(["app"]); - expect( - tools.get("delete_auth_login_connection")?.config._meta.ui.visibility, - ).toEqual(["app"]); + expect(tools.has("delete_auth_login_connection")).toBe(false); }); - test("app-only tool schemas reject empty identifiers", () => { + test("app-only tool schema rejects an empty connection identifier", () => { const { tools } = captureRegistration(); - const deleteSchema = z.object( - tools.get("delete_auth_login_connection")!.config.inputSchema, - ); - expect( - deleteSchema.safeParse({ - connection_id: "conn_1", - app_capability: "", - }).success, - ).toBe(false); - expect( - deleteSchema.safeParse({ connection_id: "", app_capability: "cap" }) - .success, - ).toBe(false); const statusSchema = z.object( tools.get("get_auth_login_status")!.config.inputSchema, ); @@ -203,28 +186,7 @@ describe("managed-auth MCP App registration", () => { expect(JSON.stringify(result)).not.toContain("?code="); expect(JSON.stringify(result)).not.toContain("handoff_code"); expect(JSON.stringify(result)).not.toContain("hosted_url"); - // The delete authorization capability travels only in _meta, never in - // model-visible content or structuredContent. - expect(result._meta.auth_login_launcher.app_capability).toBeString(); - expect(JSON.stringify(result.content)).not.toContain("app_capability"); - expect(JSON.stringify(result.structuredContent)).not.toContain( - "app_capability", - ); - }); - - test("launcher issues no app_capability to hosts without MCP Apps support", async () => { - const { tools } = captureRegistration({ appsSupport: false }); - const result = await tools.get("open_auth_login")!.handler( - { - mode: "new_login", - domain: "example.com", - profile_name: "work", - text_only: false, - }, - { authInfo: { token: "unused-api-key" } }, - ); expect(result._meta).toBeUndefined(); - expect(JSON.stringify(result)).not.toContain("app_capability"); }); test("app-only tools fail closed on hosts without MCP Apps support", async () => { @@ -232,10 +194,6 @@ describe("managed-auth MCP App registration", () => { const calls: Array<[string, any]> = [ ["begin_auth_login", { mode: "reauth", connection_id: "conn_1" }], ["get_auth_login_status", { connection_id: "conn_1" }], - [ - "delete_auth_login_connection", - { connection_id: "conn_1", app_capability: "forged" }, - ], ]; for (const [name, params] of calls) { const result = await tools.get(name)!.handler(params, { @@ -252,30 +210,31 @@ describe("managed-auth MCP App registration", () => { // Simulates the streamable-HTTP path: no client capabilities on the // per-request server, but the route layer recorded the capability. redisMarkerPresent = true; + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => ({ + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "AUTHENTICATED", + }), + }, + }, + }); try { const { tools } = captureRegistration({ appsSupport: false }); const result = await tools - .get("delete_auth_login_connection")! + .get("get_auth_login_status")! .handler( - { connection_id: "conn_1", app_capability: "forged" }, + { connection_id: "conn_1" }, { authInfo: { token: "unused-api-key" } }, ); - // Past the gate: fails on the forged capability, not the gate. - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("authorization is invalid"); - - const launcher = await tools.get("open_auth_login")!.handler( - { - mode: "new_login", - domain: "example.com", - profile_name: "work", - text_only: false, - }, - { authInfo: { token: "unused-api-key" } }, - ); - expect(launcher._meta.auth_login_launcher.app_capability).toBeString(); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent.kind).toBe("kernel.managed_auth.status"); } finally { redisMarkerPresent = false; + resetKernelClientFactory(); } }); @@ -468,19 +427,6 @@ describe("managed-auth MCP App registration", () => { } }); - test("destructive app-only cleanup rejects calls without the private launcher capability", async () => { - const { tools } = captureRegistration(); - const result = await tools.get("delete_auth_login_connection")!.handler( - { - connection_id: "connection-id", - app_capability: "not-valid", - }, - { authInfo: { token: "unused-api-key" } }, - ); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("authorization is invalid"); - }); - test("bundle is self-contained and has no hosted iframe", () => { expect(MANAGED_AUTH_APP_HTML.length).toBeGreaterThan(100_000); expect(MANAGED_AUTH_APP_HTML).not.toContain(" Date.now() - ); - } catch { - return false; - } -} - const authLoginInputSchema = { mode: z.enum(["new_login", "reauth"]), connection_id: z.string().min(1).optional(), @@ -254,16 +196,6 @@ export function registerAuthLoginApp(server: McpServer) { profile_name: input.profile_name!, wait_seconds: 25, }; - // The delete authorization capability is App-only: issue it solely to - // hosts that declared MCP Apps support, and keep it in _meta (which - // hosts do not add to model context). It never goes in - // structuredContent, which non-Apps hosts may surface to the model. - const appCapability = (await clientSupportsMcpApps( - server, - extra.authInfo.token, - )) - ? issueAppCapability(extra.authInfo.token) - : null; return { content: [ { @@ -282,13 +214,6 @@ export function registerAuthLoginApp(server: McpServer) { arguments: waitArguments, }, }, - ...(appCapability && { - _meta: { - auth_login_launcher: { - app_capability: appCapability, - }, - }, - }), }; } catch (error) { return errorResponse( @@ -422,50 +347,4 @@ export function registerAuthLoginApp(server: McpServer) { } }, ); - - server.registerTool( - "delete_auth_login_connection", - { - title: "Delete managed-auth connection (app-only)", - description: - "Delete the managed-auth connection created by the secure App, including QA cleanup.", - inputSchema: { - connection_id: z.string().min(1), - app_capability: z.string().min(1), - }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: true, - }, - _meta: { ui: { visibility: ["app"] } }, - }, - async (params, extra) => { - if (!extra.authInfo) throw new Error("Authentication required"); - const gateError = await mcpAppsGateError( - server, - extra.authInfo.token, - MCP_APPS_GATE_DENIED_MESSAGE, - ); - if (gateError) return errorResponse(gateError); - if (!validAppCapability(params.app_capability, extra.authInfo.token)) { - return errorResponse("Secure App authorization is invalid or expired."); - } - const client = createKernelClient(extra.authInfo.token); - try { - await client.auth.connections.delete(params.connection_id); - return { - content: [ - { - type: "text" as const, - text: "Managed-auth connection deleted.", - }, - ], - }; - } catch { - return errorResponse("Managed-auth connection could not be deleted."); - } - }, - ); } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 60def23..6865fe2 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -266,7 +266,6 @@ async function readBrowserTelemetry( function browserSessionNextActions(sessionId: string) { return [ - `Use show_browser_live_view with session_id "${sessionId}" to display an embedded live view of this browser to the user.`, `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, `Use manage_browsers with action "get" and session_id "${sessionId}" for full browser details.`, `Use manage_browsers with action "delete" and session_id "${sessionId}" when the session is no longer needed.`, @@ -346,7 +345,7 @@ export function registerBrowserCapabilities(server: McpServer) { // manage_browsers -- Manage browser sessions and read archived telemetry server.tool( "manage_browsers", - 'Manage browser sessions and their archived telemetry. Before creating a browser for a site that requires login, first call manage_auth_connections(action="list", domain_filter=...), inspect all pages, ask the user to choose if multiple profiles match, and use the returned authenticated profile_name. Never create a fresh browser when an authenticated managed-auth profile is available. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', + 'Manage browser sessions and their archived telemetry. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished.', { action: z .enum(["create", "update", "list", "get", "get_telemetry", "delete"]) @@ -396,7 +395,7 @@ export function registerBrowserCapabilities(server: McpServer) { profile_name: z .string() .describe( - "(create, update) Profile name to load saved cookies/logins. For protected sites, obtain this from manage_auth_connections after domain-filtered discovery and user selection. Continue only when that connection is AUTHENTICATED. Cannot use with profile_id.", + "(create, update) Profile name to load saved cookies/logins. Cannot use with profile_id.", ) .optional(), profile_id: z diff --git a/src/lib/mcp/tools/live-view-app.test.ts b/src/lib/mcp/tools/live-view-app.test.ts deleted file mode 100644 index 443f95f..0000000 --- a/src/lib/mcp/tools/live-view-app.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { registerLiveViewApp } from "@/lib/mcp/tools/live-view-app"; - -// Tests that exercise API-backed handlers substitute a fake Kernel client. -const unusedKernelClient = new Proxy( - {}, - { - get: () => { - throw new Error("unexpected Kernel client use"); - }, - }, -); -let kernelClientFactory: (token: string) => any = () => unusedKernelClient; - -// The capability gate falls back to a Redis marker (recorded by the route -// layer at initialize) on stateless transports. Tests control it directly. -let redisMarkerPresent = false; -mock.module("@/lib/redis", () => ({ - hasMcpAppsClient: async () => redisMarkerPresent, - markMcpAppsClient: async () => {}, -})); -mock.module("@/lib/mcp/kernel-client", () => ({ - createKernelClient: (token: string) => kernelClientFactory(token), -})); - -type ToolRegistration = { - config: Record; - handler: (params: any, extra: any) => Promise; -}; - -function captureRegistration({ appsSupport = true } = {}) { - const tools = new Map(); - const clientCapabilities = appsSupport - ? { - extensions: { - "io.modelcontextprotocol/ui": {}, - }, - } - : {}; - const registrationHandle = () => ({ - enable() {}, - disable() {}, - }); - const server = { - server: { - getClientCapabilities: () => clientCapabilities, - }, - registerResource() { - return registrationHandle(); - }, - registerTool( - name: string, - config: Record, - handler: ToolRegistration["handler"], - ) { - tools.set(name, { config, handler }); - return registrationHandle(); - }, - } as unknown as McpServer; - registerLiveViewApp(server); - return { tools }; -} - -const authedExtra = { authInfo: { token: "test-token" } }; - -function fakeScreenshotClient() { - const png = Buffer.from("fake-png-bytes"); - return { - browsers: { - computer: { - captureScreenshot: async () => ({ - blob: async () => new Blob([png], { type: "image/png" }), - }), - }, - }, - }; -} - -describe("live view MCP App", () => { - test("live view tool schemas reject an empty session_id", () => { - const { tools } = captureRegistration(); - for (const name of ["show_browser_live_view", "capture_live_view_frame"]) { - const schema = z.object(tools.get(name)!.config.inputSchema); - expect(schema.safeParse({ session_id: "" }).success).toBe(false); - expect(schema.safeParse({ session_id: "sess_1" }).success).toBe(true); - } - }); - - test("capture_live_view_frame fails closed on hosts without MCP Apps support", async () => { - redisMarkerPresent = false; - const { tools } = captureRegistration({ appsSupport: false }); - const result = await tools - .get("capture_live_view_frame")! - .handler({ session_id: "sess_1" }, authedExtra); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - "only available to the embedded Kernel live view App", - ); - }); - - test("capture_live_view_frame passes the gate via the recorded initialize marker", async () => { - redisMarkerPresent = true; - kernelClientFactory = () => fakeScreenshotClient(); - try { - const { tools } = captureRegistration({ appsSupport: false }); - const result = await tools - .get("capture_live_view_frame")! - .handler({ session_id: "sess_1" }, authedExtra); - expect(result.isError).toBeUndefined(); - expect(result.content[0].type).toBe("image"); - expect(result.content[0].mimeType).toBe("image/png"); - expect(Buffer.from(result.content[0].data, "base64").toString()).toBe( - "fake-png-bytes", - ); - } finally { - redisMarkerPresent = false; - kernelClientFactory = () => unusedKernelClient; - } - }); - - test("capture_live_view_frame executes for MCP Apps-capable hosts", async () => { - redisMarkerPresent = false; - kernelClientFactory = () => fakeScreenshotClient(); - try { - const { tools } = captureRegistration({ appsSupport: true }); - const result = await tools - .get("capture_live_view_frame")! - .handler({ session_id: "sess_1" }, authedExtra); - expect(result.isError).toBeUndefined(); - expect(result.content[0].type).toBe("image"); - } finally { - kernelClientFactory = () => unusedKernelClient; - } - }); -}); diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts deleted file mode 100644 index 0e7ad08..0000000 --- a/src/lib/mcp/tools/live-view-app.ts +++ /dev/null @@ -1,581 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; -import { errorResponse, toolErrorResponse } from "@/lib/mcp/responses"; -import { mcpAppsGateError } from "@/lib/mcp/tools/mcp-apps-gate"; - -/** - * MCP Apps (SEP-1865) prototype: an embedded view of a Kernel browser - * session, rendered inline in MCP Apps-capable hosts (Claude Desktop, etc). - * - * The `show_browser_live_view` tool is linked (via `_meta.ui.resourceUri`) to - * a `ui://` HTML resource. Hosts that support the `io.modelcontextprotocol/ui` - * extension fetch that resource, render it in a sandboxed iframe, and deliver - * the tool result to it over postMessage JSON-RPC. - * - * The view renders read-only snapshots of the browser: it polls the app-only - * `capture_live_view_frame` tool through the host and swaps PNG frames into an - * . We deliberately do NOT iframe the real live view — hosts like Claude - * block nested third-party iframes (frameDomains is restricted pending - * security review), and snapshots work everywhere with no external origins. - * The full interactive live view is still one click away via ui/open-link. - * - * Hosts without MCP Apps support just see a normal tool that returns the live - * view URLs as text. - */ - -// Bumping the version in the URI busts host-side resource caches (hosts MAY -// prefetch and cache UI resources); the unversioned legacy URI stays -// registered below for hosts still holding cached tool metadata. -export const LIVE_VIEW_RESOURCE_URI = "ui://kernel/live-view-v2.html"; - -const LIVE_VIEW_LEGACY_RESOURCE_URI = "ui://kernel/live-view.html"; - -export const LIVE_VIEW_MIME_TYPE = "text/html;profile=mcp-app"; - -// No external origins needed: snapshots arrive as data: URIs via host-proxied -// tool calls, which the spec's restrictive default CSP already permits. -const LIVE_VIEW_UI_META = { - prefersBorder: true, -}; - -const SNAPSHOT_INTERVAL_MS = 2500; -const SNAPSHOT_RETRY_MS = 4000; -const SNAPSHOT_MAX_FAILURES = 5; - -// The view is a single self-contained HTML document speaking raw postMessage -// JSON-RPC per the MCP Apps spec (no bundler / SDK build step needed). -const LIVE_VIEW_HTML = /* html */ ` - - - - - Kernel Browser Live View - - - -
-
- Kernel browser - connecting - - - -
-
-
Waiting for browser…
- -
-
session
-
-
- - - -`; - -export function registerLiveViewApp(server: McpServer) { - const readLiveViewResource = async (uri: URL) => ({ - contents: [ - { - uri: uri.toString(), - mimeType: LIVE_VIEW_MIME_TYPE, - text: LIVE_VIEW_HTML, - _meta: { ui: LIVE_VIEW_UI_META }, - }, - ], - }); - - for (const [name, uri] of [ - ["kernel-live-view", LIVE_VIEW_RESOURCE_URI], - ["kernel-live-view-legacy", LIVE_VIEW_LEGACY_RESOURCE_URI], - ] as const) { - server.registerResource( - name, - uri, - { - title: "Kernel Browser Live View", - description: - "Interactive view that shows read-only snapshots of a Kernel browser session inline in the conversation.", - mimeType: LIVE_VIEW_MIME_TYPE, - _meta: { ui: LIVE_VIEW_UI_META }, - }, - readLiveViewResource, - ); - } - - server.registerTool( - "show_browser_live_view", - { - title: "Show browser live view", - description: - "Render an embedded, read-only view of a Kernel browser session directly in the chat so the user can watch automation happen in real time (refreshing snapshots). Call this once right after creating a (non-headless) browser session, before starting to control it; the view keeps refreshing while you work. On hosts without MCP Apps support this returns the live view URLs as text instead.", - inputSchema: { - session_id: z - .string() - .min(1) - .describe("Browser session ID to display (from manage_browsers)."), - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - _meta: { - ui: { - resourceUri: LIVE_VIEW_RESOURCE_URI, - visibility: ["model", "app"], - }, - // Deprecated flat form kept for hosts on the pre-GA metadata shape. - "ui/resourceUri": LIVE_VIEW_RESOURCE_URI, - }, - }, - async (params, extra) => { - if (!extra.authInfo) throw new Error("Authentication required"); - const client = createKernelClient(extra.authInfo.token); - - try { - const browser = await client.browsers.retrieve(params.session_id); - if (!browser) { - return errorResponse( - `Browser session "${params.session_id}" not found`, - ); - } - const interactiveUrl = browser.browser_live_view_url; - if (!interactiveUrl) { - return errorResponse( - `Browser session "${params.session_id}" has no live view URL (headless sessions have no live view).`, - ); - } - const readOnlyUrl = new URL(interactiveUrl); - readOnlyUrl.searchParams.set("readOnly", "true"); - - return { - content: [ - { - type: "text" as const, - text: - `Live view for browser session ${params.session_id} is now displayed to the user (read-only snapshots).` + - ` Read-only URL: ${readOnlyUrl.toString()}` + - ` | Interactive URL: ${interactiveUrl}`, - }, - ], - structuredContent: { - session_id: params.session_id, - live_view_url: readOnlyUrl.toString(), - interactive_live_view_url: interactiveUrl, - read_only: true, - }, - }; - } catch (error) { - return toolErrorResponse("show_browser_live_view", "show", error); - } - }, - ); - - // App-only snapshot tool: the render loop of the live view app. Hidden from - // the model on compliant hosts via visibility: ["app"]. - server.registerTool( - "capture_live_view_frame", - { - title: "Capture live view frame (app-only)", - description: - "Capture a PNG snapshot of a browser session for the embedded live view app. Intended for app-initiated calls; agents should use computer_action with a screenshot action instead.", - inputSchema: { - session_id: z - .string() - .min(1) - .describe("Browser session ID to capture a frame from."), - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - _meta: { - ui: { - visibility: ["app"], - }, - }, - }, - async (params, extra) => { - if (!extra.authInfo) throw new Error("Authentication required"); - // Hidden from the model via visibility: ["app"], but hosts without MCP - // Apps support may ignore that hint. Fail closed like the other - // app-only tools so the model cannot poll screenshots directly. - const gateError = await mcpAppsGateError( - server, - extra.authInfo.token, - "This tool is only available to the embedded Kernel live view App on MCP Apps-capable hosts and cannot be called by the model. Use computer_action with a screenshot action instead.", - ); - if (gateError) return errorResponse(gateError); - const client = createKernelClient(extra.authInfo.token); - try { - const screenshotResponse = - await client.browsers.computer.captureScreenshot(params.session_id); - const blob = await screenshotResponse.blob(); - const buffer = Buffer.from(await blob.arrayBuffer()); - return { - content: [ - { - type: "image" as const, - data: buffer.toString("base64"), - mimeType: "image/png", - }, - ], - }; - } catch (error) { - return toolErrorResponse("capture_live_view_frame", "capture", error); - } - }, - ); -} diff --git a/src/lib/mcp/tools/playwright.ts b/src/lib/mcp/tools/playwright.ts index 35232a1..9e8f3cc 100644 --- a/src/lib/mcp/tools/playwright.ts +++ b/src/lib/mcp/tools/playwright.ts @@ -11,7 +11,7 @@ export function registerPlaywrightTool(server: McpServer) { code: z .string() .describe( - "Playwright/TypeScript code with `page`, `context`, and `browser` objects in scope; the value you `return` is sent back. Example: `await page.goto('https://example.com'); return await page.title();` Return only what you need — prefer a targeted selector (e.g. `await page.locator('h1').innerText()`) or a region-scoped snapshot (e.g. `await page.locator('main').ariaSnapshot()`) rather than dumping the whole page.", + 'Playwright/TypeScript code with `page`, `context`, and `browser` objects in scope; the value you `return` is sent back. Example: `await page.goto(\'https://example.com\'); return await page.title();` Return only what you need — prefer a targeted selector (e.g. `await page.locator(\'h1\').innerText()`) or a region-scoped snapshot (e.g. `await page.locator(\'main\').ariaSnapshot()`) rather than dumping the whole page.', ), session_id: z .string() From 1dec6d25198add6c704d0d393cc93907caa68e46 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 16:44:30 -0400 Subject: [PATCH 11/12] fix: close scoped managed-auth Bugbot findings --- src/lib/mcp/tools/auth-connections.test.ts | 36 +++++++++++++++ src/lib/mcp/tools/auth-connections.ts | 11 +++++ src/lib/mcp/tools/auth-login-app.test.ts | 48 +++++++++++++++++++- src/lib/mcp/tools/auth-login-app.ts | 34 +++++++++++++- src/lib/mcp/tools/managed-auth-state.ts | 53 ++++++++++++++++++++-- src/lib/mcp/tools/mcp-apps-gate.ts | 28 +++++------- 6 files changed, 187 insertions(+), 23 deletions(-) diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index d178fcd..5e02c88 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -480,6 +480,42 @@ describe("managed-auth wait", () => { expect(result.state).toBe("authenticated"); }); + test("timeline identity completes a null-expiry baseline when polling misses the live flow", async () => { + const completed = connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "REAUTH", + flow_expires_at: null, + }); + const client = { + auth: { + connections: { + retrieve: async () => completed, + timeline: async () => ({ + getPaginatedItems: () => [ + { + id: "flow_new", + type: "reauth", + status: "SUCCESS", + timestamp: "2026-01-02T00:00:00Z", + }, + ], + }), + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: completed.id, + previousFlowExpiresAt: null, + previousFlowEventId: "flow_old", + }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("authenticated"); + }); + test("baseline-guarded wait stays pending on the stale pre-flow success", async () => { const stale = connection({ status: "AUTHENTICATED", diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 469ddef..24d1fb0 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -62,6 +62,14 @@ export function registerAuthConnectionTools(server: McpServer) { "(wait) Baseline flow expiry supplied by open_auth_login; do not modify.", ) .optional(), + previous_flow_event_id: z + .string() + .min(1) + .nullable() + .describe( + "(wait) Baseline timeline event supplied by open_auth_login; do not modify.", + ) + .optional(), ...paginationParams, }, { @@ -143,6 +151,9 @@ export function registerAuthConnectionTools(server: McpServer) { ...(params.previous_flow_expires_at !== undefined && { previousFlowExpiresAt: params.previous_flow_expires_at, }), + ...(params.previous_flow_event_id !== undefined && { + previousFlowEventId: params.previous_flow_event_id, + }), }, { timeoutMs: (params.wait_seconds ?? 25) * 1_000, diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index e523287..dd0c9b9 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -271,7 +271,7 @@ describe("managed-auth MCP App registration", () => { }, }, ]), - ).toBe(true); + ).toBe(false); expect( initializeDeclaresMcpApps({ jsonrpc: "2.0", @@ -337,6 +337,52 @@ describe("managed-auth MCP App registration", () => { } }); + test("reauth launcher includes a timeline baseline when flow expiry is null", async () => { + kernelClientFactory = () => ({ + auth: { + connections: { + retrieve: async () => ({ + id: "conn_1", + domain: "example.com", + profile_name: "work", + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "LOGIN", + flow_expires_at: null, + }), + timeline: async () => ({ + getPaginatedItems: () => [ + { + id: "flow_old", + type: "login", + status: "SUCCESS", + timestamp: "2026-01-01T00:00:00Z", + }, + ], + }), + }, + }, + }); + try { + const { tools } = captureRegistration(); + const result = await tools + .get("open_auth_login")! + .handler( + { mode: "reauth", connection_id: "conn_1", text_only: false }, + { authInfo: { token: "unused-api-key" } }, + ); + expect(result.structuredContent.next_action.arguments).toEqual({ + action: "wait", + id: "conn_1", + wait_seconds: 25, + previous_flow_expires_at: null, + previous_flow_event_id: "flow_old", + }); + } finally { + resetKernelClientFactory(); + } + }); + test("reauth launcher observing a live flow emits no baseline guard", async () => { kernelClientFactory = () => ({ auth: { diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index e173c2c..408c111 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -1,7 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { MANAGED_AUTH_APP_HTML } from "@/lib/mcp/apps/generated/managed-auth-app"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { initializeDeclaresMcpApps, mcpAppsGateError, @@ -52,6 +52,25 @@ const authLoginInputSchema = { proxy_name: z.string().min(1).optional(), }; +async function latestAuthFlowEventId( + client: KernelClient, + connectionId: string, +): Promise { + try { + const page = await client.auth.connections.timeline(connectionId, { + limit: 10, + }); + return ( + page + .getPaginatedItems() + .find((event) => event.type === "login" || event.type === "reauth") + ?.id ?? null + ); + } catch { + return undefined; + } +} + function inputFromParams(params: AuthLoginInput): AuthLoginInput { return { mode: params.mode, @@ -124,6 +143,9 @@ export function registerAuthLoginApp(server: McpServer) { try { if (params.text_only) { + const previousFlowEventId = input.connection_id + ? await latestAuthFlowEventId(client, input.connection_id) + : undefined; const result = await beginAuthLogin(client, input); // Guard the wait with the pre-flow baseline captured by begin so a // completed flow from before this call is never accepted as the new @@ -136,6 +158,9 @@ export function registerAuthLoginApp(server: McpServer) { wait_seconds: 25, ...(result.started_new_flow && { previous_flow_expires_at: result.previous_flow_expires_at, + ...(previousFlowEventId !== undefined && { + previous_flow_event_id: previousFlowEventId, + }), }), }; const content: Array<{ @@ -177,6 +202,10 @@ export function registerAuthLoginApp(server: McpServer) { domain: input.domain!, profile_name: input.profile_name!, }; + const previousFlowEventId = + reauthConnection && !hasLiveAuthFlow(reauthConnection) + ? await latestAuthFlowEventId(client, reauthConnection.id) + : undefined; const waitArguments = reauthConnection ? { action: "wait", @@ -188,6 +217,9 @@ export function registerAuthLoginApp(server: McpServer) { // observes that flow, so no baseline is needed. ...(!hasLiveAuthFlow(reauthConnection) && { previous_flow_expires_at: reauthConnection.flow_expires_at, + ...(previousFlowEventId !== undefined && { + previous_flow_event_id: previousFlowEventId, + }), }), } : { diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 4296e6a..7e5d6f1 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -96,6 +96,7 @@ export interface AuthWaitSelector { profileName?: string; requiredFlowType?: "LOGIN" | "REAUTH"; previousFlowExpiresAt?: string | null; + previousFlowEventId?: string | null; } export interface AuthWaitResult { @@ -180,6 +181,18 @@ async function findAuthConnection( return matches[0] ?? null; } +async function latestAuthFlowEvent(client: KernelClient, connectionId: string) { + const page = await client.auth.connections.timeline(connectionId, { + limit: 10, + }); + return ( + page + .getPaginatedItems() + .find((event) => event.type === "login" || event.type === "reauth") ?? + null + ); +} + function authWaitDelay(milliseconds: number, signal?: AbortSignal) { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -212,6 +225,9 @@ export async function waitForAuthConnection( const deadline = Date.now() + timeoutMs; let observedQuery = false; let observedLiveFlow = false; + let observedNewFlow = false; + let observedNewFlowSucceeded = false; + let observedNewFlowFailed = false; let latest: SafeAuthConnection | undefined; do { @@ -224,16 +240,40 @@ export async function waitForAuthConnection( if (connection) { latest = toSafeAuthConnection(connection); if (hasLiveAuthFlow(latest)) observedLiveFlow = true; + if ( + selector.previousFlowEventId !== undefined && + selector.connectionId + ) { + try { + const event = await latestAuthFlowEvent( + client, + selector.connectionId, + ); + if (event && event.id !== selector.previousFlowEventId) { + observedNewFlow = true; + observedNewFlowSucceeded = event.status === "SUCCESS"; + observedNewFlowFailed = + event.status === "FAILED" || + event.status === "EXPIRED" || + event.status === "CANCELED"; + } + } catch { + // Keep polling with the connection-level guards when timeline + // lookup is transiently unavailable. + } + } // A wait is flow-guarded when the caller supplied a baseline (and/or a // required flow type): only a flow that completed after that baseline // counts. The flow type is never assumed ahead of time — the server // chooses LOGIN vs REAUTH — so any successful new flow satisfies the - // guard. The observed-live-flow backstop covers servers that clear - // flow_expires_at once a flow reaches a terminal state. + // guard. Timeline identity closes the case where the API clears + // flow_expires_at before polling observes the in-progress state. const flowGuarded = selector.requiredFlowType !== undefined || - selector.previousFlowExpiresAt !== undefined; + selector.previousFlowExpiresAt !== undefined || + selector.previousFlowEventId !== undefined; const flowFailed = + observedNewFlowFailed || latest.flow_status === "FAILED" || latest.flow_status === "EXPIRED" || latest.flow_status === "CANCELED"; @@ -243,15 +283,18 @@ export async function waitForAuthConnection( // the failure, so the wait must not report success. A terminal // failure with no live flow observed predates this wait and leaves // the authenticated state usable. - const observedFlowFailed = observedLiveFlow && flowFailed; + const observedFlowFailed = + (observedLiveFlow || observedNewFlow) && flowFailed; const requiredFlowCompleted = !flowGuarded || + observedNewFlowSucceeded || (latest.flow_status === "SUCCESS" && (selector.requiredFlowType === undefined || latest.flow_type === selector.requiredFlowType) && (selector.previousFlowExpiresAt === undefined || latest.flow_expires_at !== selector.previousFlowExpiresAt || - observedLiveFlow)); + observedLiveFlow) && + (selector.previousFlowEventId === undefined || observedNewFlow)); // AUTHENTICATED with a live in-progress flow means a (re-)auth is // still running: report pending instead of the stale pre-flow state. if ( diff --git a/src/lib/mcp/tools/mcp-apps-gate.ts b/src/lib/mcp/tools/mcp-apps-gate.ts index 18eed5a..9632399 100644 --- a/src/lib/mcp/tools/mcp-apps-gate.ts +++ b/src/lib/mcp/tools/mcp-apps-gate.ts @@ -10,24 +10,20 @@ export const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui"; const MCP_APPS_MARKER_TTL_SECONDS = 24 * 60 * 60; /** - * Whether a JSON-RPC payload (single message or batch) is an initialize that - * declares MCP Apps support. The route layer uses this to record the client - * capability, because the stateless streamable-HTTP transport does not expose - * it to later requests. + * Whether a JSON-RPC payload is a standalone initialize request that declares + * MCP Apps support. Mixed batches fail closed so they cannot self-attest and + * invoke an app-only tool in the same HTTP request. */ export function initializeDeclaresMcpApps(body: unknown): boolean { - const messages = Array.isArray(body) ? body : [body]; - return messages.some((message) => { - if (!message || typeof message !== "object") return false; - const request = message as { - method?: unknown; - params?: { capabilities?: { extensions?: Record } }; - }; - return ( - request.method === "initialize" && - Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION]) - ); - }); + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + const request = body as { + method?: unknown; + params?: { capabilities?: { extensions?: Record } }; + }; + return ( + request.method === "initialize" && + Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION]) + ); } /** From 855a54efae0ba346c0fce0fa619d8d0e3e4b5eb3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 30 Jul 2026 16:57:16 -0400 Subject: [PATCH 12/12] fix: align managed auth app with guarded wait state --- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 89 +++++++++++++++---- src/lib/mcp/tools/auth-connections.test.ts | 38 +++++++- src/lib/mcp/tools/auth-connections.ts | 11 ++- src/lib/mcp/tools/auth-login-app.test.ts | 3 + src/lib/mcp/tools/auth-login-app.ts | 39 ++++---- src/lib/mcp/tools/managed-auth-state.ts | 28 ++++-- 7 files changed, 168 insertions(+), 42 deletions(-) diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index 3bcd63f..a23a3da 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +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
Preparing secure login…
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index 44b4c30..f15d7c6 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -61,6 +61,11 @@ type BeginResult = { }; isError?: boolean; }; +type WaitToolResult = { + structuredContent?: { + state?: "authenticated" | "failed" | "pending"; + }; +}; let nextRequestId = 1; const pendingRequests = new Map(); @@ -118,11 +123,11 @@ function sendNotification(method: string, params: JsonObject) { postToHost({ jsonrpc: "2.0", method, params }); } -function callTool(name: string, args: JsonObject): Promise { +function callTool(name: string, args: JsonObject): Promise { return sendRequest("tools/call", { name, arguments: args, - }) as Promise; + }) as Promise; } function applyHostContext(context: JsonObject | undefined) { @@ -247,6 +252,38 @@ function sanitizeBeginArguments(input: JsonObject): JsonObject { ); } +function waitArgumentsFromLauncher( + content: JsonObject | undefined, +): JsonObject | null { + const nextAction = content?.next_action as + | { tool?: unknown; arguments?: JsonObject } + | undefined; + if ( + nextAction?.tool !== "manage_auth_connections" || + nextAction.arguments?.action !== "wait" + ) { + return null; + } + const allowed = [ + "action", + "id", + "domain_filter", + "profile_name", + "required_flow_type", + "previous_flow_expires_at", + "previous_flow_event_id", + "flow_wait_started_at", + ]; + return { + ...Object.fromEntries( + allowed + .filter((key) => nextAction.arguments?.[key] !== undefined) + .map((key) => [key, nextAction.arguments?.[key]]), + ), + wait_seconds: 1, + }; +} + function ManagedAuthApp() { const launcher = useLauncherData(); const [beginResult, setBeginResult] = useState(null); @@ -266,6 +303,7 @@ function ManagedAuthApp() { const launcherContent = launcher.result?.structuredContent as | { connection?: { domain?: string; profile_name?: string }; + next_action?: { tool?: string; arguments?: JsonObject }; } | undefined; const targetDomain = @@ -277,6 +315,9 @@ function ManagedAuthApp() { const privateAuth = beginResult?._meta?.auth_login ?? beginResult?.structuredContent?.app_private; + const waitArguments = waitArgumentsFromLauncher( + launcher.result?.structuredContent as JsonObject | undefined, + ); const appearance = useMemo( () => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }), @@ -427,29 +468,41 @@ function ManagedAuthApp() { }); const current = result.structuredContent?.connection; if (!current) throw new Error("No connection status returned"); - if ( + const expiresAt = current.flow_expires_at + ? Date.parse(current.flow_expires_at) + : Number.NaN; + const statusClaimsFailure = current.flow_status === "FAILED" || current.flow_status === "EXPIRED" || - current.flow_status === "CANCELED" - ) { - finish("failure"); + current.flow_status === "CANCELED" || + (current.flow_status !== "SUCCESS" && + Number.isFinite(expiresAt) && + expiresAt <= Date.now()); + const statusClaimsSuccess = + current.flow_status === "SUCCESS" && current.status === "AUTHENTICATED"; + if ((statusClaimsFailure || statusClaimsSuccess) && waitArguments) { + const wait = await callTool( + "manage_auth_connections", + waitArguments, + ); + const waitState = wait.structuredContent?.state; + if (waitState === "authenticated") { + finish("success"); + return; + } + if (waitState === "failed") { + finish("failure"); + return; + } + setStatusText("Secure login is still in progress…"); + pollTimer.current = window.setTimeout(checkStatus, 2000); return; } - const expiresAt = current.flow_expires_at - ? Date.parse(current.flow_expires_at) - : Number.NaN; - if ( - current.flow_status !== "SUCCESS" && - Number.isFinite(expiresAt) && - expiresAt <= Date.now() - ) { + if (statusClaimsFailure) { finish("failure"); return; } - if ( - current.flow_status === "SUCCESS" && - current.status === "AUTHENTICATED" - ) { + if (statusClaimsSuccess) { finish("success"); return; } diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index 5e02c88..abc9fb0 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -509,13 +509,49 @@ describe("managed-auth wait", () => { { connectionId: completed.id, previousFlowExpiresAt: null, - previousFlowEventId: "flow_old", + flowWaitStartedAt: "2026-01-01T00:00:00Z", }, { timeoutMs: 0 }, ); expect(result.state).toBe("authenticated"); }); + test("timeline timestamp ignores a stale event when no prior event id exists", async () => { + const stale = connection({ + status: "AUTHENTICATED", + flow_status: "SUCCESS", + flow_type: "REAUTH", + flow_expires_at: null, + }); + const client = { + auth: { + connections: { + retrieve: async () => stale, + timeline: async () => ({ + getPaginatedItems: () => [ + { + id: "flow_old", + type: "reauth", + status: "SUCCESS", + timestamp: "2026-01-01T00:00:00Z", + }, + ], + }), + }, + }, + } as unknown as KernelClient; + const result = await waitForAuthConnection( + client, + { + connectionId: stale.id, + previousFlowExpiresAt: null, + flowWaitStartedAt: "2026-01-02T00:00:00Z", + }, + { timeoutMs: 0 }, + ); + expect(result.state).toBe("pending"); + }); + test("baseline-guarded wait stays pending on the stale pre-flow success", async () => { const stale = connection({ status: "AUTHENTICATED", diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 24d1fb0..dfeb1de 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -65,11 +65,17 @@ export function registerAuthConnectionTools(server: McpServer) { previous_flow_event_id: z .string() .min(1) - .nullable() .describe( "(wait) Baseline timeline event supplied by open_auth_login; do not modify.", ) .optional(), + flow_wait_started_at: z + .string() + .min(1) + .describe( + "(wait) Baseline timestamp supplied by open_auth_login; do not modify.", + ) + .optional(), ...paginationParams, }, { @@ -154,6 +160,9 @@ export function registerAuthConnectionTools(server: McpServer) { ...(params.previous_flow_event_id !== undefined && { previousFlowEventId: params.previous_flow_event_id, }), + ...(params.flow_wait_started_at !== undefined && { + flowWaitStartedAt: params.flow_wait_started_at, + }), }, { timeoutMs: (params.wait_seconds ?? 25) * 1_000, diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index dd0c9b9..fa7d50d 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -377,6 +377,7 @@ describe("managed-auth MCP App registration", () => { wait_seconds: 25, previous_flow_expires_at: null, previous_flow_event_id: "flow_old", + flow_wait_started_at: expect.any(String), }); } finally { resetKernelClientFactory(); @@ -483,6 +484,8 @@ describe("managed-auth MCP App registration", () => { test("terminal model context excludes internal identifiers and never writes the prompt", () => { expect(MANAGED_AUTH_APP_HTML).toContain("profile_name"); + expect(MANAGED_AUTH_APP_HTML).toContain("manage_auth_connections"); + expect(MANAGED_AUTH_APP_HTML).toContain("flow_wait_started_at"); expect(MANAGED_AUTH_APP_HTML).toContain( "Connection status saved for Claude", ); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 408c111..c2a8a1b 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -52,20 +52,21 @@ const authLoginInputSchema = { proxy_name: z.string().min(1).optional(), }; -async function latestAuthFlowEventId( +async function authFlowWaitBaseline( client: KernelClient, connectionId: string, -): Promise { +): Promise<{ eventId?: string; startedAt: string } | undefined> { try { const page = await client.auth.connections.timeline(connectionId, { limit: 10, }); - return ( - page - .getPaginatedItems() - .find((event) => event.type === "login" || event.type === "reauth") - ?.id ?? null - ); + const eventId = page + .getPaginatedItems() + .find((event) => event.type === "login" || event.type === "reauth")?.id; + return { + ...(eventId && { eventId }), + startedAt: new Date().toISOString(), + }; } catch { return undefined; } @@ -143,8 +144,8 @@ export function registerAuthLoginApp(server: McpServer) { try { if (params.text_only) { - const previousFlowEventId = input.connection_id - ? await latestAuthFlowEventId(client, input.connection_id) + const flowWaitBaseline = input.connection_id + ? await authFlowWaitBaseline(client, input.connection_id) : undefined; const result = await beginAuthLogin(client, input); // Guard the wait with the pre-flow baseline captured by begin so a @@ -158,8 +159,11 @@ export function registerAuthLoginApp(server: McpServer) { wait_seconds: 25, ...(result.started_new_flow && { previous_flow_expires_at: result.previous_flow_expires_at, - ...(previousFlowEventId !== undefined && { - previous_flow_event_id: previousFlowEventId, + ...(flowWaitBaseline && { + ...(flowWaitBaseline.eventId && { + previous_flow_event_id: flowWaitBaseline.eventId, + }), + flow_wait_started_at: flowWaitBaseline.startedAt, }), }), }; @@ -202,9 +206,9 @@ export function registerAuthLoginApp(server: McpServer) { domain: input.domain!, profile_name: input.profile_name!, }; - const previousFlowEventId = + const flowWaitBaseline = reauthConnection && !hasLiveAuthFlow(reauthConnection) - ? await latestAuthFlowEventId(client, reauthConnection.id) + ? await authFlowWaitBaseline(client, reauthConnection.id) : undefined; const waitArguments = reauthConnection ? { @@ -217,8 +221,11 @@ export function registerAuthLoginApp(server: McpServer) { // observes that flow, so no baseline is needed. ...(!hasLiveAuthFlow(reauthConnection) && { previous_flow_expires_at: reauthConnection.flow_expires_at, - ...(previousFlowEventId !== undefined && { - previous_flow_event_id: previousFlowEventId, + ...(flowWaitBaseline && { + ...(flowWaitBaseline.eventId && { + previous_flow_event_id: flowWaitBaseline.eventId, + }), + flow_wait_started_at: flowWaitBaseline.startedAt, }), }), } diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 7e5d6f1..bf502cd 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -96,7 +96,8 @@ export interface AuthWaitSelector { profileName?: string; requiredFlowType?: "LOGIN" | "REAUTH"; previousFlowExpiresAt?: string | null; - previousFlowEventId?: string | null; + previousFlowEventId?: string; + flowWaitStartedAt?: string; } export interface AuthWaitResult { @@ -241,7 +242,8 @@ export async function waitForAuthConnection( latest = toSafeAuthConnection(connection); if (hasLiveAuthFlow(latest)) observedLiveFlow = true; if ( - selector.previousFlowEventId !== undefined && + (selector.previousFlowEventId !== undefined || + selector.flowWaitStartedAt !== undefined) && selector.connectionId ) { try { @@ -249,7 +251,20 @@ export async function waitForAuthConnection( client, selector.connectionId, ); - if (event && event.id !== selector.previousFlowEventId) { + const eventTimestamp = event + ? Date.parse(event.timestamp) + : Number.NaN; + const waitStartedAt = selector.flowWaitStartedAt + ? Date.parse(selector.flowWaitStartedAt) + : Number.NaN; + const isNewEvent = event + ? selector.previousFlowEventId !== undefined + ? event.id !== selector.previousFlowEventId + : Number.isFinite(eventTimestamp) && + Number.isFinite(waitStartedAt) && + eventTimestamp >= waitStartedAt + : false; + if (event && isNewEvent) { observedNewFlow = true; observedNewFlowSucceeded = event.status === "SUCCESS"; observedNewFlowFailed = @@ -271,7 +286,8 @@ export async function waitForAuthConnection( const flowGuarded = selector.requiredFlowType !== undefined || selector.previousFlowExpiresAt !== undefined || - selector.previousFlowEventId !== undefined; + selector.previousFlowEventId !== undefined || + selector.flowWaitStartedAt !== undefined; const flowFailed = observedNewFlowFailed || latest.flow_status === "FAILED" || @@ -294,7 +310,9 @@ export async function waitForAuthConnection( (selector.previousFlowExpiresAt === undefined || latest.flow_expires_at !== selector.previousFlowExpiresAt || observedLiveFlow) && - (selector.previousFlowEventId === undefined || observedNewFlow)); + ((selector.previousFlowEventId === undefined && + selector.flowWaitStartedAt === undefined) || + observedNewFlow)); // AUTHENTICATED with a live in-progress flow means a (re-)auth is // still running: report pending instead of the stale pre-flow state. if (