Skip to content

Add secure Managed Auth MCP App - #129

Merged
rgarcia merged 27 commits into
mainfrom
rgarcia/managed-auth-mcp-app
Aug 4, 2026
Merged

Add secure Managed Auth MCP App#129
rgarcia merged 27 commits into
mainfrom
rgarcia/managed-auth-mcp-app

Conversation

@rgarcia

@rgarcia rgarcia commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a native Managed Auth MCP App using @onkernel/managed-auth-react
  • preserve the full programmatic manage_auth_connections tool (create, list, get, delete, login, submit, and the new read-only wait) for every client
  • register one additional model-facing tool, open_auth_login, only when the client declares MCP Apps support
  • keep credentials and MFA values outside model-visible traffic when the secure App path is used; compatibility submissions are never echoed in responses
  • add a narrowly scoped same-origin Managed Auth relay
  • long-poll authentication status so Claude can continue the pending turn without injecting text into the composer
  • expose managed-auth replay and browser telemetry configuration; the secure App defaults both recording and operational telemetry on

Safety properties

  • opening the App does not create or start a login flow before the user clicks Continue
  • the model must obtain consent before launching authentication
  • The app-only begin helper is hidden from the model and fails closed when the client did not declare MCP Apps support
  • authentication is not treated as complete until the connection reports AUTHENTICATED
  • re-auth waits do not accept stale authenticated state from an earlier flow
  • the relay accepts only allowlisted operations and valid, unexpired managed-auth scoped JWTs
  • App-path handoff material stays behind the negotiated app-only boundary; submitted compatibility fields are never echoed in responses

Compatibility

MCP Apps support is strictly additive. Every client receives the same full manage_auth_connections tool. Clients declaring io.modelcontextprotocol/ui additionally receive open_auth_login; its implementation tool is app-only and hidden from the model. Clients without MCP Apps do not see the launcher and continue using the established programmatic sequence. manage_credentials remains unchanged.

Validation

  • bun x tsc --noEmit --incremental false
  • bun test (60 tests)
  • bun run check:managed-auth-app
  • Prettier check on changed source/docs
  • git diff --check
  • bun run build
  • manual Claude Desktop QA through ngrok

Note

High Risk
Changes authentication flows, credential boundaries, and a security-sensitive upstream relay; incorrect gating or checkpoint/wait logic could leak secrets or accept stale auth state.

Overview
Introduces secure Managed Auth for MCP Apps clients: a bundled React App (@onkernel/managed-auth-react), model-facing open_auth_login, and app-only begin_auth_login, so credentials and MFA stay out of model-visible traffic. Non–MCP Apps clients keep the same full manage_auth_connections surface, including a new read-only wait action with signed flow checkpoints so agents can long-poll without treating stale authenticated state as success during reauth.

Transport and registration now mint HMAC-signed MCP transport session tokens on streamable initialize, store per-subject/per-session MCP Apps markers in Redis, and route to an expanded tool/resource set only when the client declared io.modelcontextprotocol/ui—failing closed on mixed batches or missing session identity.

A narrow same-origin relay (/managed-auth-proxy/auth/connections/...) proxies only allowlisted Kernel auth connection operations to API_BASE_URL, with scoped-JWT validation, body limits, stripped cookies/arbitrary headers, and unbuffered SSE for events.

Build/CI: Bun 1.3.3 pins reproducible App bundle generation (build:managed-auth-app / check:managed-auth-app), bumps @onkernel/sdk to ^0.85.0, and documents MANAGED_AUTH_APP_ORIGIN plus auth_connections in optional toolset gating.

Reviewed by Cursor Bugbot for commit f3f70ed. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mcp Ready Ready Preview Aug 4, 2026 7:10pm

@socket-security

socket-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​onkernel/​managed-auth-react@​0.4.17710010094100
Addedbun@​1.3.3911007995100
Updated@​onkernel/​sdk@​0.78.0 ⏵ 0.85.082 +1100100 +199 +1100

View full report

Comment thread src/lib/mcp/apps/managed-auth-entry.tsx Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Auth ID params lack min(1)
    • Updated auth connection identifier schemas to use z.string().min(1) (including optional connection_id) so empty IDs are rejected at validation time.
  • ✅ Fixed: Text-only wait skips re-auth guards
    • Adjusted wait completion logic to treat AUTHENTICATED plus flow_status: IN_PROGRESS as pending when no required flow is provided, and text-only guidance now reuses structured wait arguments.
  • ✅ Fixed: Reauth wait blocks NEEDS_AUTH login
    • Changed reauth launcher wait arguments to require REAUTH only for already-authenticated connections so NEEDS_AUTH logins can complete with LOGIN flow success.

Create PR

Or push these changes by commenting:

@cursor push 9f7b8ef04e
Preview (9f7b8ef04e)
diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts
--- 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

@@ -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.ts b/src/lib/mcp/tools/auth-login-app.ts
--- a/src/lib/mcp/tools/auth-login-app.ts
+++ b/src/lib/mcp/tools/auth-login-app.ts
@@ -91,14 +91,35 @@ 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(),
   proxy_id: z.string().optional(),
   proxy_name: z.string().optional(),
 };
 
+function waitArgsForConnection(options: {
+  mode: AuthLoginInput["mode"];
+  connection: Pick<
+    ReturnType<typeof toSafeAuthConnection>,
+    "id" | "status" | "flow_status" | "flow_expires_at"
+  >;
+}) {
+  const requiresReauthFlow =
+    options.mode === "reauth" && options.connection.status === "AUTHENTICATED";
+  return {
+    action: "wait" as const,
+    id: options.connection.id,
+    wait_seconds: 25,
+    ...(requiresReauthFlow && { required_flow_type: "REAUTH" as const }),
+    ...(requiresReauthFlow &&
+      options.connection.flow_status !== "IN_PROGRESS" && {
+        previous_flow_expires_at: options.connection.flow_expires_at,
+      }),
+  };
+}
+
 function inputFromParams(params: AuthLoginInput): AuthLoginInput {
   return {
     mode: params.mode,

@@ -91,14 +91,35 @@ 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(),
   proxy_id: z.string().optional(),
   proxy_name: z.string().optional(),
 };
 
+function waitArgsForConnection(options: {
+  mode: AuthLoginInput["mode"];
+  connection: Pick<
+    ReturnType<typeof toSafeAuthConnection>,
+    "id" | "status" | "flow_status" | "flow_expires_at"
+  >;
+}) {
+  const requiresReauthFlow =
+    options.mode === "reauth" && options.connection.status === "AUTHENTICATED";
+  return {
+    action: "wait" as const,
+    id: options.connection.id,
+    wait_seconds: 25,
+    ...(requiresReauthFlow && { required_flow_type: "REAUTH" as const }),
+    ...(requiresReauthFlow &&
+      options.connection.flow_status !== "IN_PROGRESS" && {
+        previous_flow_expires_at: options.connection.flow_expires_at,
+      }),
+  };
+}
+
 function inputFromParams(params: AuthLoginInput): AuthLoginInput {
   return {
     mode: params.mode,
@@ -172,14 +193,18 @@ export function registerAuthLoginApp(server: McpServer) {
       try {
         if (params.text_only) {
           const result = await beginAuthLogin(client, input);
+          const waitArguments = waitArgsForConnection({
+            mode: input.mode,
+            connection: result.connection,
+          });
           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. 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 while pending and continue only when it returns authenticated.`,
             },
           ];
           if (result.hosted_url) {

@@ -172,14 +193,18 @@ export function registerAuthLoginApp(server: McpServer) {
       try {
         if (params.text_only) {
           const result = await beginAuthLogin(client, input);
+          const waitArguments = waitArgsForConnection({
+            mode: input.mode,
+            connection: result.connection,
+          });
           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. 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 while pending and continue only when it returns authenticated.`,
             },
           ];
           if (result.hosted_url) {
@@ -212,16 +237,10 @@ export function registerAuthLoginApp(server: McpServer) {
           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,
-                }),
-            }
+          ? waitArgsForConnection({
+              mode: input.mode,
+              connection: reauthConnection,
+            })
           : {
               action: "wait",
               domain_filter: input.domain!,

@@ -212,16 +237,10 @@ export function registerAuthLoginApp(server: McpServer) {
           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,
-                }),
-            }
+          ? waitArgsForConnection({
+              mode: input.mode,
+              connection: reauthConnection,
+            })
           : {
               action: "wait",
               domain_filter: input.domain!,
@@ -339,7 +358,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,

@@ -339,7 +358,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,
@@ -384,7 +403,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: {

@@ -384,7 +403,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: {

diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts
--- a/src/lib/mcp/tools/managed-auth-state.ts
+++ b/src/lib/mcp/tools/managed-auth-state.ts
@@ -216,7 +216,7 @@ export async function waitForAuthConnection(
             latest.flow_type === selector.requiredFlowType &&
             (selector.previousFlowExpiresAt === undefined ||
               latest.flow_expires_at !== selector.previousFlowExpiresAt)
-          : true;
+          : latest.flow_status !== "IN_PROGRESS";
         if (latest.status === "AUTHENTICATED" && requiredFlowCompleted) {
           return { state: "authenticated", connection: latest };
         }

@@ -216,7 +216,7 @@ export async function waitForAuthConnection(
             latest.flow_type === selector.requiredFlowType &&
             (selector.previousFlowExpiresAt === undefined ||
               latest.flow_expires_at !== selector.previousFlowExpiresAt)
-          : true;
+          : latest.flow_status !== "IN_PROGRESS";
         if (latest.status === "AUTHENTICATED" && requiredFlowCompleted) {
           return { state: "authenticated", connection: latest };
         }

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/auth-connections.ts
Comment thread src/lib/mcp/tools/auth-login-app.ts Outdated
Comment thread src/lib/mcp/tools/auth-login-app.ts Outdated
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.
@rgarcia

rgarcia commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Empty app capability accepted
    • Updated delete_auth_login_connection to validate app_capability with z.string().min(1) so empty values are rejected at schema validation time.
  • ✅ Fixed: Unguarded wait ignores failed flow
    • Reordered waitForAuthConnection terminal-flow handling so FAILED/EXPIRED/CANCELED states are returned as failed before the authenticated success branch.
  • ✅ Fixed: Live view app tool ungated
    • Added MCP Apps capability gating to capture_live_view_frame using the same fail-closed gate used by managed-auth app-only tools.
  • ✅ Fixed: Discovery ignores live re-auth
    • Discovery and get-path guidance now treat authenticated connections with a live in-progress auth flow as not yet verified and steer back to waiting/login flow handling.

Create PR

Or push these changes by commenting:

@cursor push c91f7f6ba7
Preview (c91f7f6ba7)
diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts
--- a/src/lib/mcp/tools/auth-connections.ts
+++ b/src/lib/mcp/tools/auth-connections.ts
@@ -4,6 +4,7 @@
 import {
   AuthLoginStartError,
   deriveAuthNextAction,
+  hasLiveAuthFlow,
   toSafeAuthConnection,
   waitForAuthConnection,
 } from "@/lib/mcp/tools/managed-auth-state";
@@ -108,12 +109,16 @@
             const connection = await client.auth.connections.retrieve(
               params.id,
             );
+            const safeConnection = toSafeAuthConnection(connection);
+            const hasInProgressFlow = hasLiveAuthFlow(connection);
             return safeJsonResponse({
-              connection: toSafeAuthConnection(connection),
+              connection: safeConnection,
               instruction:
-                connection.status === "AUTHENTICATED"
+                connection.status === "AUTHENTICATED" && !hasInProgressFlow
                   ? "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.",
+                  : hasInProgressFlow
+                    ? "Authentication is still in progress. Continue waiting with manage_auth_connections action=wait before proceeding."
+                    : "Do not continue the protected action. Ask for consent, then use open_auth_login to authenticate securely.",
             });
           }
           case "wait": {

diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts
--- a/src/lib/mcp/tools/auth-login-app.ts
+++ b/src/lib/mcp/tools/auth-login-app.ts
@@ -70,7 +70,7 @@
   }
 }
 
-async function mcpAppsGateError(
+export async function mcpAppsGateError(
   server: McpServer,
   authToken: string,
 ): Promise<string | null> {
@@ -478,7 +478,7 @@
         "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,

diff --git a/src/lib/mcp/tools/live-view-app.ts b/src/lib/mcp/tools/live-view-app.ts
--- a/src/lib/mcp/tools/live-view-app.ts
+++ b/src/lib/mcp/tools/live-view-app.ts
@@ -2,6 +2,7 @@
 import { z } from "zod";
 import { createKernelClient } from "@/lib/mcp/kernel-client";
 import { errorResponse, toolErrorResponse } from "@/lib/mcp/responses";
+import { mcpAppsGateError } from "@/lib/mcp/tools/auth-login-app";
 
 /**
  * MCP Apps (SEP-1865) prototype: an embedded view of a Kernel browser
@@ -546,6 +547,8 @@
     },
     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 screenshotResponse =

diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts
--- a/src/lib/mcp/tools/managed-auth-state.ts
+++ b/src/lib/mcp/tools/managed-auth-state.ts
@@ -236,16 +236,7 @@
             (selector.previousFlowExpiresAt === undefined ||
               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 (
           latest.flow_status === "FAILED" ||
           latest.flow_status === "EXPIRED" ||
           latest.flow_status === "CANCELED"
@@ -262,6 +253,15 @@
             return { state: "failed", connection: latest };
           }
         }
+        // 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 };
+        }
       }
     } catch (error) {
       if (error instanceof AuthLoginStartError) throw error;
@@ -368,7 +368,7 @@
   }
 
   const connection = items[0];
-  if (connection.status === "AUTHENTICATED") {
+  if (connection.status === "AUTHENTICATED" && !hasLiveAuthFlow(connection)) {
     return {
       selection: { domain_filter, outcome: "single" },
       next_action: {

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/auth-login-app.ts Outdated
Comment thread src/lib/mcp/tools/managed-auth-state.ts Outdated
Comment thread src/lib/mcp/tools/managed-auth-state.ts Outdated
Comment thread src/lib/mcp/tools/live-view-app.ts Outdated
- 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).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Get ignores in-progress reauth
    • The get action now treats AUTHENTICATED connections with a live flow as pending and instructs callers to continue waiting instead of claiming verification.
  • ✅ Fixed: New login wait accepts stale auth
    • The new_login launcher now always includes a wait baseline (captured from any existing exact match or null) so wait is flow-guarded and does not accept unguarded stale auth.
  • ✅ Fixed: Live view session_id allows empty
    • Both live-view tools now validate session_id with z.string().min(1), preventing empty IDs at schema validation time.

Create PR

Or push these changes by commenting:

@cursor push 7b88c6d180
Preview (7b88c6d180)
diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts
--- a/src/lib/mcp/tools/auth-connections.ts
+++ b/src/lib/mcp/tools/auth-connections.ts
@@ -4,6 +4,7 @@
 import {
   AuthLoginStartError,
   deriveAuthNextAction,
+  hasLiveAuthFlow,
   toSafeAuthConnection,
   waitForAuthConnection,
 } from "@/lib/mcp/tools/managed-auth-state";
@@ -108,12 +109,16 @@
             const connection = await client.auth.connections.retrieve(
               params.id,
             );
+            const safeConnection = toSafeAuthConnection(connection);
+            const authFlowInProgress = hasLiveAuthFlow(safeConnection);
             return safeJsonResponse({
-              connection: toSafeAuthConnection(connection),
+              connection: safeConnection,
               instruction:
-                connection.status === "AUTHENTICATED"
+                safeConnection.status === "AUTHENTICATED" && !authFlowInProgress
                   ? "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.",
+                  : authFlowInProgress
+                    ? "Authentication is still pending. Immediately call manage_auth_connections with action=wait and this id again. Do not continue the protected action yet."
+                    : "Do not continue the protected action. Ask for consent, then use open_auth_login to authenticate securely.",
             });
           }
           case "wait": {

diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts
--- a/src/lib/mcp/tools/auth-login-app.test.ts
+++ b/src/lib/mcp/tools/auth-login-app.test.ts
@@ -180,6 +180,7 @@
           domain_filter: "example.com",
           profile_name: "work",
           wait_seconds: 25,
+          previous_flow_expires_at: null,
         },
       },
     });
@@ -210,6 +211,50 @@
     expect(JSON.stringify(result)).not.toContain("app_capability");
   });
 
+  test("new_login launcher guards wait with any matching pre-flow baseline", async () => {
+    kernelClientFactory = () => ({
+      auth: {
+        connections: {
+          list: async () => ({
+            getPaginatedItems: () => [
+              {
+                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",
+              },
+            ],
+            hasNextPage: () => false,
+          }),
+        },
+      },
+    });
+    try {
+      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.next_action.arguments).toEqual({
+        action: "wait",
+        domain_filter: "example.com",
+        profile_name: "work",
+        wait_seconds: 25,
+        previous_flow_expires_at: "2026-01-01T00:00:00Z",
+      });
+    } finally {
+      resetKernelClientFactory();
+    }
+  });
+
   test("app-only tools fail closed on hosts without MCP Apps support", async () => {
     const { tools } = captureRegistration({ appsSupport: false });
     const calls: Array<[string, any]> = [

diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts
--- a/src/lib/mcp/tools/auth-login-app.ts
+++ b/src/lib/mcp/tools/auth-login-app.ts
@@ -231,6 +231,32 @@
                 await client.auth.connections.retrieve(input.connection_id!),
               )
             : null;
+        const previousFlowExpiresAtForNewLogin =
+          !reauthConnection && input.mode === "new_login"
+            ? await (async () => {
+                try {
+                  const page = await client.auth.connections.list({
+                    domain: input.domain!,
+                    profile_name: input.profile_name!,
+                    limit: 100,
+                  });
+                  const matches = page
+                    .getPaginatedItems()
+                    .filter(
+                      (item) =>
+                        item.domain === input.domain &&
+                        item.profile_name === input.profile_name,
+                    );
+                  if (matches.length === 1 && !page.hasNextPage()) {
+                    return matches[0].flow_expires_at ?? null;
+                  }
+                } catch {
+                  // Keep launcher behavior available even if discovery fails;
+                  // a null baseline still guards against unflowed stale auth.
+                }
+                return null;
+              })()
+            : undefined;
         const connection = reauthConnection ?? {
           domain: input.domain!,
           profile_name: input.profile_name!,
@@ -253,6 +279,7 @@
               domain_filter: input.domain!,
               profile_name: input.profile_name!,
               wait_seconds: 25,
+              previous_flow_expires_at: previousFlowExpiresAtForNewLogin,
             };
         // The delete authorization capability is App-only: issue it solely to
         // hosts that declared MCP Apps support, and keep it in _meta (which

diff --git a/src/lib/mcp/tools/live-view-app.test.ts b/src/lib/mcp/tools/live-view-app.test.ts
--- 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,18 @@
 }
 
 describe("live view MCP App", () => {
+  test("live view tool schemas reject empty session identifiers", () => {
+    const { tools } = captureRegistration();
+    const showSchema = z.object(
+      tools.get("show_browser_live_view")!.config.inputSchema,
+    );
+    expect(showSchema.safeParse({ session_id: "" }).success).toBe(false);
+    const captureSchema = z.object(
+      tools.get("capture_live_view_frame")!.config.inputSchema,
+    );
+    expect(captureSchema.safeParse({ session_id: "" }).success).toBe(false);
+  });
+
   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
--- a/src/lib/mcp/tools/live-view-app.ts
+++ b/src/lib/mcp/tools/live-view-app.ts
@@ -460,6 +460,7 @@
       inputSchema: {
         session_id: z
           .string()
+          .min(1)
           .describe("Browser session ID to display (from manage_browsers)."),
       },
       annotations: {
@@ -531,6 +532,7 @@
       inputSchema: {
         session_id: z
           .string()
+          .min(1)
           .describe("Browser session ID to capture a frame from."),
       },
       annotations: {

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/auth-connections.ts
Comment thread src/lib/mcp/tools/auth-login-app.ts
Comment thread src/lib/mcp/tools/live-view-app.ts Outdated
- 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Stale failure aborts pre-flow wait
    • I updated waitForAuthConnection so unguarded domain/profile waits treat terminal failures as pre-existing until a live flow is observed, preventing stale failures from aborting pre-flow waits.
  • ✅ Fixed: Wait succeeds during in-progress flow
    • I prevented authenticated success when flow_status is IN_PROGRESS, so waits now remain pending even if the flow expiry is missing or stale.
  • ✅ Fixed: Empty proxy IDs pass validation
    • I changed proxy_id and proxy_name schemas to z.string().min(1).optional() so empty strings are rejected at validation time.

Create PR

Or push these changes by commenting:

@cursor push 22e843f300
Preview (22e843f300)
diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts
--- a/src/lib/mcp/tools/auth-connections.test.ts
+++ b/src/lib/mcp/tools/auth-connections.test.ts
@@ -360,6 +360,26 @@
     expect(result.state).toBe("pending");
   });
 
+  test("treats authenticated with IN_PROGRESS flow status as pending when the flow expiry is stale", async () => {
+    const inProgressWithStaleExpiry = connection({
+      status: "AUTHENTICATED",
+      flow_status: "IN_PROGRESS",
+      flow_type: "REAUTH",
+      flow_expires_at: "2000-01-01T00:00:00Z",
+    });
+    const client = {
+      auth: {
+        connections: { retrieve: async () => inProgressWithStaleExpiry },
+      },
+    } as unknown as KernelClient;
+    const result = await waitForAuthConnection(
+      client,
+      { connectionId: inProgressWithStaleExpiry.id },
+      { timeoutMs: 0 },
+    );
+    expect(result.state).toBe("pending");
+  });
+
   test("accepts authenticated once the live flow succeeds, without any flow guard", async () => {
     const states = [
       connection({
@@ -559,6 +579,31 @@
     expect(result.state).toBe("pending");
   });
 
+  test("domain/profile wait ignores stale terminal failures before a new flow starts", async () => {
+    const staleFailed = connection({
+      status: "NEEDS_AUTH",
+      flow_status: "FAILED",
+      flow_type: "LOGIN",
+      flow_expires_at: "2020-01-01T00:00:00Z",
+    });
+    const client = {
+      auth: {
+        connections: {
+          list: async () => ({
+            getPaginatedItems: () => [staleFailed],
+            hasNextPage: () => false,
+          }),
+        },
+      },
+    } as unknown as KernelClient;
+    const result = await waitForAuthConnection(
+      client,
+      { domain: "example.com", profileName: "work" },
+      { 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-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts
--- a/src/lib/mcp/tools/auth-login-app.test.ts
+++ b/src/lib/mcp/tools/auth-login-app.test.ts
@@ -156,6 +156,49 @@
     expect(statusSchema.safeParse({ connection_id: "" }).success).toBe(false);
   });
 
+  test("open and begin schemas reject empty proxy identifiers", () => {
+    const { tools } = captureRegistration();
+    const launcherSchema = z.object(
+      tools.get("open_auth_login")!.config.inputSchema,
+    );
+    expect(
+      launcherSchema.safeParse({
+        mode: "new_login",
+        domain: "example.com",
+        profile_name: "work",
+        proxy_id: "",
+      }).success,
+    ).toBe(false);
+    expect(
+      launcherSchema.safeParse({
+        mode: "new_login",
+        domain: "example.com",
+        profile_name: "work",
+        proxy_name: "",
+      }).success,
+    ).toBe(false);
+
+    const beginSchema = z.object(
+      tools.get("begin_auth_login")!.config.inputSchema,
+    );
+    expect(
+      beginSchema.safeParse({
+        mode: "new_login",
+        domain: "example.com",
+        profile_name: "work",
+        proxy_id: "",
+      }).success,
+    ).toBe(false);
+    expect(
+      beginSchema.safeParse({
+        mode: "new_login",
+        domain: "example.com",
+        profile_name: "work",
+        proxy_name: "",
+      }).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
--- a/src/lib/mcp/tools/auth-login-app.ts
+++ b/src/lib/mcp/tools/auth-login-app.ts
@@ -106,8 +106,8 @@
   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
--- a/src/lib/mcp/tools/managed-auth-state.ts
+++ b/src/lib/mcp/tools/managed-auth-state.ts
@@ -251,6 +251,7 @@
         // still running: report pending instead of the stale pre-flow state.
         if (
           latest.status === "AUTHENTICATED" &&
+          latest.flow_status !== "IN_PROGRESS" &&
           !hasLiveAuthFlow(latest) &&
           !observedFlowFailed &&
           requiredFlowCompleted
@@ -258,15 +259,18 @@
           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 &&
-            !observedLiveFlow &&
-            selector.previousFlowExpiresAt !== undefined &&
-            latest.flow_expires_at === selector.previousFlowExpiresAt;
-          if (!terminalIsBaseline) {
+          // For domain/profile waits launched before begin_auth_login, a stale
+          // terminal flow can predate this wait entirely. Keep polling until
+          // a live flow is observed instead of failing immediately.
+          const terminalPredatesWait =
+            (!flowGuarded &&
+              !observedLiveFlow &&
+              selector.connectionId === undefined) ||
+            (flowGuarded &&
+              !observedLiveFlow &&
+              selector.previousFlowExpiresAt !== undefined &&
+              latest.flow_expires_at === selector.previousFlowExpiresAt);
+          if (!terminalPredatesWait) {
             return { state: "failed", connection: latest };
           }
         }

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/managed-auth-state.ts
Comment thread src/lib/mcp/tools/managed-auth-state.ts Outdated
Comment thread src/lib/mcp/tools/auth-login-app.ts Outdated
- 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: OAuth refresh breaks Apps marker
    • MCP Apps markers are now stored and checked under both the bearer-token hash and a stable JWT session-id hash so refreshed access tokens keep app-only tool access without re-initialize.
  • ✅ Fixed: Relay ignores JWT expiration time
    • The managed-auth relay now rejects bearer JWTs whose numeric exp claim is missing, non-finite, or not strictly in the future before forwarding upstream.

Create PR

Or push these changes by commenting:

@cursor push 1e4d433e17
Preview (1e4d433e17)
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
--- a/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts
+++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts
@@ -15,6 +15,11 @@
   managed_auth_session_id: "session_1",
   exp: 4102444800,
 });
+const expiredScopedToken = jwt({
+  iss: "kernel-api",
+  managed_auth_session_id: "session_1",
+  exp: 1,
+});
 
 function request(
   path: string,
@@ -70,6 +75,15 @@
     );
     expect(apiKey.status).toBe(401);
     expectCors(apiKey);
+
+    const expired = await proxyManagedAuthRequest(
+      request("/managed-auth-proxy/auth/connections/c_1", {
+        headers: { authorization: `Bearer ${expiredScopedToken}` },
+      }),
+      ["c_1"],
+    );
+    expect(expired.status).toBe(401);
+    expectCors(expired);
   });
 
   test("allows unauthenticated exchange and strips cookies and arbitrary headers", async () => {

diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts
--- a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts
+++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts
@@ -53,11 +53,14 @@
   const match = authorization?.match(/^Bearer\s+([^\s]+)$/i);
   if (!match) return null;
   const claims = decodeJwtPayload(match[1]);
+  const nowSeconds = Math.floor(Date.now() / 1000);
   if (
     claims?.iss !== "kernel-api" ||
     typeof claims.managed_auth_session_id !== "string" ||
     !claims.managed_auth_session_id ||
-    typeof claims.exp !== "number"
+    typeof claims.exp !== "number" ||
+    !Number.isFinite(claims.exp) ||
+    claims.exp <= nowSeconds
   ) {
     return null;
   }

diff --git a/src/lib/redis.ts b/src/lib/redis.ts
--- a/src/lib/redis.ts
+++ b/src/lib/redis.ts
@@ -152,8 +152,9 @@
 // (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.
+// keyed by the bearer token plus JWT session claim when available, so
+// refreshed access tokens from the same OAuth session keep the marker alive
+// while app-only tools still fail closed on hosts that never declared support.
 const MCP_APPS_KEY_PREFIX = "mcp-apps:";
 
 function hashBearerToken(token: string): string {
@@ -164,6 +165,33 @@
   return createHmac("sha256", secretKey).update(token).digest("hex");
 }
 
+function decodeJwtPayload(token: string): Record<string, unknown> | null {
+  const parts = token.split(".");
+  if (parts.length !== 3) return null;
+  try {
+    return JSON.parse(
+      Buffer.from(parts[1], "base64url").toString("utf8"),
+    ) as Record<string, unknown>;
+  } catch {
+    return null;
+  }
+}
+
+function mcpAppsMarkerKeys(token: string): string[] {
+  const keys = [`${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`];
+  const claims = decodeJwtPayload(token);
+  const sessionId =
+    typeof claims?.sid === "string" && claims.sid
+      ? claims.sid
+      : typeof claims?.session_id === "string" && claims.session_id
+        ? claims.session_id
+        : null;
+  if (sessionId) {
+    keys.push(`${MCP_APPS_KEY_PREFIX}session:${hashOpaqueToken(sessionId)}`);
+  }
+  return keys;
+}
+
 export async function markMcpAppsClient({
   token,
   ttlSeconds,
@@ -172,10 +200,10 @@
   ttlSeconds: number;
 }): Promise<void> {
   await ensureConnected();
-  const key = `${MCP_APPS_KEY_PREFIX}${hashBearerToken(token)}`;
-  await withReconnect(() =>
-    client.setEx(key, Math.max(60, Math.floor(ttlSeconds)), "1"),
-  );
+  const ttl = Math.max(60, Math.floor(ttlSeconds));
+  for (const key of mcpAppsMarkerKeys(token)) {
+    await withReconnect(() => client.setEx(key, ttl, "1"));
+  }
 }
 
 /**
@@ -190,12 +218,13 @@
   ttlSeconds: number;
 }): Promise<boolean> {
   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))),
-  );
+  const ttl = Math.max(60, Math.floor(ttlSeconds));
+  const keys = mcpAppsMarkerKeys(token);
+  const values = await withReconnect(() => client.mGet(keys));
+  if (!values.some((value) => value !== null)) return false;
+  for (const key of keys) {
+    await withReconnect(() => client.expire(key, ttl));
+  }
   return true;
 }

You can send follow-ups to the cloud agent here.

Comment thread src/lib/redis.ts Outdated
Comment thread src/app/managed-auth-proxy/auth/connections/[...path]/route.ts
- 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.
Comment thread src/lib/mcp/tools/managed-auth-state.ts Outdated
@rgarcia

rgarcia commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: MCP Apps gate batch bypass
    • Capability marking now only accepts standalone initialize payloads (not JSON-RPC batches), preventing mixed initialize+tool-call requests from self-attesting and bypassing the MCP Apps gate.

Create PR

Or push these changes by commenting:

@cursor push f222d64657
Preview (f222d64657)
diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts
--- a/src/lib/mcp/tools/auth-login-app.test.ts
+++ b/src/lib/mcp/tools/auth-login-app.test.ts
@@ -271,7 +271,7 @@
           },
         },
       ]),
-    ).toBe(true);
+    ).toBe(false);
     expect(
       initializeDeclaresMcpApps({
         jsonrpc: "2.0",

diff --git a/src/lib/mcp/tools/mcp-apps-gate.ts b/src/lib/mcp/tools/mcp-apps-gate.ts
--- a/src/lib/mcp/tools/mcp-apps-gate.ts
+++ b/src/lib/mcp/tools/mcp-apps-gate.ts
@@ -10,24 +10,21 @@
 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. The route layer records capability only for standalone
+ * initialize requests so a mixed batch cannot self-attest and invoke app-only
+ * tools in the same HTTP call.
  */
 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<string, unknown> } };
-    };
-    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<string, unknown> } };
+  };
+  return (
+    request.method === "initialize" &&
+    Boolean(request.params?.capabilities?.extensions?.[MCP_APPS_EXTENSION])
+  );
 }
 
 /**

You can send follow-ups to the cloud agent here.

Comment thread src/app/[transport]/route.ts Outdated
Comment thread src/lib/mcp/apps/managed-auth-entry.tsx Outdated
…mcp-app

# Conflicts:
#	.github/workflows/ci.yml
#	src/app/[transport]/route.ts
#	src/lib/mcp/tools/auth-connections.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Telemetry schema uses strict()
    • Removed .strict() from the managed auth browser telemetry schemas so MCP tool params now use plain z.object() and drop unknown keys instead of rejecting them.

Create PR

Or push these changes by commenting:

@cursor push 94c4f2fc69
Preview (94c4f2fc69)
diff --git a/src/lib/mcp/tools/managed-auth-telemetry.ts b/src/lib/mcp/tools/managed-auth-telemetry.ts
--- a/src/lib/mcp/tools/managed-auth-telemetry.ts
+++ b/src/lib/mcp/tools/managed-auth-telemetry.ts
@@ -1,31 +1,26 @@
 import { z } from "zod";
 
-const telemetryCategorySchema = z
-  .object({
-    enabled: z.boolean().optional(),
-  })
-  .strict();
+const telemetryCategorySchema = z.object({
+  enabled: z.boolean().optional(),
+});
 
-const telemetryCategoriesSchema = z
-  .object({
-    captcha: telemetryCategorySchema.optional(),
-    connection: telemetryCategorySchema.optional(),
-    console: telemetryCategorySchema.optional(),
-    control: telemetryCategorySchema.optional(),
-    interaction: telemetryCategorySchema.optional(),
-    network: telemetryCategorySchema.optional(),
-    page: telemetryCategorySchema.optional(),
-    screenshot: telemetryCategorySchema.optional(),
-    system: telemetryCategorySchema.optional(),
-  })
-  .strict();
+const telemetryCategoriesSchema = z.object({
+  captcha: telemetryCategorySchema.optional(),
+  connection: telemetryCategorySchema.optional(),
+  console: telemetryCategorySchema.optional(),
+  control: telemetryCategorySchema.optional(),
+  interaction: telemetryCategorySchema.optional(),
+  network: telemetryCategorySchema.optional(),
+  page: telemetryCategorySchema.optional(),
+  screenshot: telemetryCategorySchema.optional(),
+  system: telemetryCategorySchema.optional(),
+});
 
 export const managedAuthBrowserTelemetrySchema = z
   .object({
     enabled: z.boolean().optional(),
     browser: telemetryCategoriesSchema.optional(),
   })
-  .strict()
   .superRefine((telemetry, context) => {
     if (
       telemetry.enabled === false &&

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 20e1b36. Configure here.

Comment thread src/lib/mcp/tools/managed-auth-telemetry.ts Outdated

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requesting changes after a full thermo-nuclear pass over all 27 changed files. the server helper tests are extensive and green, but the implementation still has two correctness/security boundary failures and a major maintainability regression:

  • MCP Apps negotiation is stored per shared bearer credential rather than per MCP client session;
  • an already-live reauth can fail before the first wait poll and be returned as authenticated;
  • flow identity is split across launcher, server waiter, and App logic, including a browser-clock timestamp;
  • the 895-line App has no executable behavioral coverage; bundle string searches do not test its state machine.

the existing unresolved checkStatus() / isError threads also need to be fixed. please split the 1,147-line auth-connections test file by concern as part of the decomposition.

validation: 66 tests pass, typecheck and changed-file formatting pass, and the generated bundle matches Bun 1.3.3. those checks do not exercise the App component, shared transport route, or Redis marker lifecycle.

Comment thread src/lib/mcp-apps-marker.ts Outdated
Comment thread src/lib/mcp/tools/auth-login-app.ts Outdated
Comment thread src/lib/mcp/apps/managed-auth-entry.tsx Outdated
Comment thread src/lib/mcp/apps/managed-auth-entry.tsx
Comment thread package.json Outdated
Comment thread package.json

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the latest changes materially improve the PR: capability state is now scoped to a signed transport session, the App forwards server-issued checkpoints, the component has been decomposed, polling errors are explicit, tests are split, and production bundle generation is pinned.

one blocking checkpoint bug remains. an after checkpoint currently means “any event whose id differs from the baseline,” so an older historical event can satisfy the wait before a new flow exists. this can report an authenticated reauth before the user clicks Continue. details and a regression case are inline.

validation at d786e5d: 80 tests pass, typecheck passes, the pinned bundle check passes, CI/BugBot are green, and git diff --check passes.

Comment thread src/lib/mcp/tools/managed-auth-state.ts Outdated

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approved after rechecking f3f70ed. the checkpoint now respects newest-first ordering, ignores older historical events, and fails closed when a non-null baseline is absent. the requested regression cases are present.

validation: 81 tests pass, typecheck passes, the pinned managed-auth bundle check passes, git diff --check passes, and CI/BugBot are green.

@rgarcia
rgarcia merged commit a3240ba into main Aug 4, 2026
10 checks passed
@rgarcia
rgarcia deleted the rgarcia/managed-auth-mcp-app branch August 4, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants