Skip to content

Add secure Managed Auth MCP App - #129

Open
rgarcia wants to merge 12 commits into
mainfrom
rgarcia/managed-auth-mcp-app
Open

Add secure Managed Auth MCP App#129
rgarcia wants to merge 12 commits into
mainfrom
rgarcia/managed-auth-mcp-app

Conversation

@rgarcia

@rgarcia rgarcia commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make manage_auth_connections the sanitized discovery and read-only wait entrypoint
  • add a native Managed Auth MCP App using @onkernel/managed-auth-react
  • keep credentials, MFA values, scoped JWTs, and API keys outside model-visible traffic
  • 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
  • preserve hosted/text-only fallback for clients without MCP Apps

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
  • App-only begin/status helpers are hidden from the model and fail 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

Validation

  • bun x tsc --noEmit --incremental false
  • bun test (43 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
Touches authentication boundaries (scoped JWT relay, app-only tools, and credential-handling UX); regressions could leak auth state or bypass the MCP Apps gate.

Overview
Introduces interactive Managed Auth so credentials and MFA stay in an MCP App instead of model-visible MCP traffic. manage_auth_connections is narrowed to sanitized discovery/list/get and long-poll wait; mutations and login start move behind open_auth_login plus app-only begin_auth_login / get_auth_login_status, which refuse clients that never declared MCP Apps support.

The MCP transport route now records MCP Apps capability from initialize in Redis (keyed by OAuth sid or token hash) so stateless HTTP can fail closed on app-only tools. A same-origin /managed-auth-proxy relay allowlists Kernel auth connection operations, validates scoped session JWTs, caps bodies, and streams SSE without forwarding cookies or arbitrary headers.

Build/ops: @onkernel/managed-auth-react, a Bun-built self-contained HTML bundle checked in CI (pinned Bun 1.3.3), MANAGED_AUTH_APP_ORIGIN, bun test, and README workflow for consent → app login → wait until authenticated (with text_only fallback).

Reviewed by Cursor Bugbot for commit 855a54e. 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 Jul 30, 2026 8:57pm

@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@​types/​bun@​1.3.141001004887100
Added@​onkernel/​managed-auth-react@​0.4.17710010094100

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

@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.

Fix All in Cursor

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

  • ✅ Fixed: App success ignores wait guards
    • The App now confirms terminal success/failure by calling manage_auth_connections with the launcher’s wait arguments before finishing, so completion only occurs when guarded wait semantics agree.
  • ✅ Fixed: Null timeline baseline accepts stale auth
    • Null previous_flow_event_id baselines are now treated as absent (not forwarded and not guard-activating), preventing stale historical SUCCESS events from satisfying a new-flow wait.

Create PR

Or push these changes by commenting:

@cursor push 9401e86a68
Preview (9401e86a68)
diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx
--- a/src/lib/mcp/apps/managed-auth-entry.tsx
+++ b/src/lib/mcp/apps/managed-auth-entry.tsx
@@ -61,6 +61,11 @@
   };
   isError?: boolean;
 };
+type WaitToolResult = {
+  structuredContent?: {
+    state?: "authenticated" | "failed" | "pending";
+  };
+};
 
 let nextRequestId = 1;
 const pendingRequests = new Map<number, PendingRequest>();
@@ -118,11 +123,11 @@
   postToHost({ jsonrpc: "2.0", method, params });
 }
 
-function callTool(name: string, args: JsonObject): Promise<BeginResult> {
+function callTool<T = BeginResult>(name: string, args: JsonObject): Promise<T> {
   return sendRequest("tools/call", {
     name,
     arguments: args,
-  }) as Promise<BeginResult>;
+  }) as Promise<T>;
 }
 
 function applyHostContext(context: JsonObject | undefined) {
@@ -247,6 +252,37 @@
   );
 }
 
+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) {
+    return null;
+  }
+  const args = nextAction.arguments;
+  if (args.action !== "wait") return null;
+  const allowed = [
+    "action",
+    "id",
+    "domain_filter",
+    "profile_name",
+    "wait_seconds",
+    "required_flow_type",
+    "previous_flow_expires_at",
+    "previous_flow_event_id",
+  ];
+  return Object.fromEntries(
+    allowed
+      .filter((key) => args[key] !== undefined)
+      .map((key) => [key, args[key]]),
+  );
+}
+
 function ManagedAuthApp() {
   const launcher = useLauncherData();
   const [beginResult, setBeginResult] = useState<BeginResult | null>(null);
@@ -266,6 +302,7 @@
   const launcherContent = launcher.result?.structuredContent as
     | {
         connection?: { domain?: string; profile_name?: string };
+        next_action?: { tool?: string; arguments?: JsonObject };
       }
     | undefined;
   const targetDomain =
@@ -277,6 +314,9 @@
   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 } }),
@@ -450,6 +490,24 @@
         current.flow_status === "SUCCESS" &&
         current.status === "AUTHENTICATED"
       ) {
+        if (waitArguments) {
+          const wait = await callTool<WaitToolResult>(
+            "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;
+        }
         finish("success");
         return;
       }

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
@@ -516,6 +516,42 @@
     expect(result.state).toBe("authenticated");
   });
 
+  test("null timeline baseline does not treat old success as a new flow", 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,
+        previousFlowEventId: null,
+      },
+      { 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
--- a/src/lib/mcp/tools/auth-connections.ts
+++ b/src/lib/mcp/tools/auth-connections.ts
@@ -151,7 +151,7 @@
                 ...(params.previous_flow_expires_at !== undefined && {
                   previousFlowExpiresAt: params.previous_flow_expires_at,
                 }),
-                ...(params.previous_flow_event_id !== undefined && {
+                ...(params.previous_flow_event_id != null && {
                   previousFlowEventId: params.previous_flow_event_id,
                 }),
               },

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
@@ -383,6 +383,47 @@
     }
   });
 
+  test("reauth launcher omits timeline baseline when no prior flow event exists", 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: () => [],
+          }),
+        },
+      },
+    });
+    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,
+      });
+      expect(
+        JSON.stringify(result.structuredContent.next_action.arguments),
+      ).not.toContain("previous_flow_event_id");
+    } 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
--- a/src/lib/mcp/tools/auth-login-app.ts
+++ b/src/lib/mcp/tools/auth-login-app.ts
@@ -55,17 +55,14 @@
 async function latestAuthFlowEventId(
   client: KernelClient,
   connectionId: string,
-): Promise<string | null | undefined> {
+): Promise<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
-    );
+    return page
+      .getPaginatedItems()
+      .find((event) => event.type === "login" || event.type === "reauth")?.id;
   } catch {
     return undefined;
   }

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
@@ -240,10 +240,7 @@
       if (connection) {
         latest = toSafeAuthConnection(connection);
         if (hasLiveAuthFlow(latest)) observedLiveFlow = true;
-        if (
-          selector.previousFlowEventId !== undefined &&
-          selector.connectionId
-        ) {
+        if (selector.previousFlowEventId != null && selector.connectionId) {
           try {
             const event = await latestAuthFlowEvent(
               client,
@@ -271,7 +268,7 @@
         const flowGuarded =
           selector.requiredFlowType !== undefined ||
           selector.previousFlowExpiresAt !== undefined ||
-          selector.previousFlowEventId !== undefined;
+          selector.previousFlowEventId != null;
         const flowFailed =
           observedNewFlowFailed ||
           latest.flow_status === "FAILED" ||
@@ -294,7 +291,7 @@
             (selector.previousFlowExpiresAt === undefined ||
               latest.flow_expires_at !== selector.previousFlowExpiresAt ||
               observedLiveFlow) &&
-            (selector.previousFlowEventId === undefined || observedNewFlow));
+            (selector.previousFlowEventId == null || 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 (

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

Reviewed by Cursor Bugbot for commit 1dec6d2. Configure here.

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

1 participant