Add secure Managed Auth MCP App - #129
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
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 optionalconnection_id) so empty IDs are rejected at validation time.
- Updated auth connection identifier schemas to use
- ✅ Fixed: Text-only wait skips re-auth guards
- Adjusted wait completion logic to treat
AUTHENTICATEDplusflow_status: IN_PROGRESSas pending when no required flow is provided, and text-only guidance now reuses structured wait arguments.
- Adjusted wait completion logic to treat
- ✅ Fixed: Reauth wait blocks NEEDS_AUTH login
- Changed reauth launcher wait arguments to require
REAUTHonly for already-authenticated connections so NEEDS_AUTH logins can complete with LOGIN flow success.
- Changed reauth launcher wait arguments to require
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.
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.
|
bugbot run |
There was a problem hiding this comment.
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.
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.
- 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).
There was a problem hiding this comment.
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.
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.
- 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.
There was a problem hiding this comment.
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
waitForAuthConnectionso unguarded domain/profile waits treat terminal failures as pre-existing until a live flow is observed, preventing stale failures from aborting pre-flow waits.
- I updated
- ✅ Fixed: Wait succeeds during in-progress flow
- I prevented authenticated success when
flow_statusisIN_PROGRESS, so waits now remain pending even if the flow expiry is missing or stale.
- I prevented authenticated success when
- ✅ Fixed: Empty proxy IDs pass validation
- I changed
proxy_idandproxy_nameschemas toz.string().min(1).optional()so empty strings are rejected at validation time.
- I changed
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.
- 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.
There was a problem hiding this comment.
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.
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.
- 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.
|
bugbot run |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
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_connectionswith the launcher’s wait arguments before finishing, so completion only occurs when guarded wait semantics agree.
- The App now confirms terminal success/failure by calling
- ✅ Fixed: Null timeline baseline accepts stale auth
- Null
previous_flow_event_idbaselines are now treated as absent (not forwarded and not guard-activating), preventing stale historical SUCCESS events from satisfying a new-flow wait.
- Null
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.


Summary
manage_auth_connectionsthe sanitized discovery and read-only wait entrypoint@onkernel/managed-auth-reactSafety properties
AUTHENTICATEDValidation
bun x tsc --noEmit --incremental falsebun test(43 tests)bun run check:managed-auth-appgit diff --checkbun run buildNote
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_connectionsis narrowed to sanitized discovery/list/get and long-pollwait; mutations and login start move behindopen_auth_loginplus app-onlybegin_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
initializein Redis (keyed by OAuthsidor token hash) so stateless HTTP can fail closed on app-only tools. A same-origin/managed-auth-proxyrelay 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 →waituntil authenticated (withtext_onlyfallback).Reviewed by Cursor Bugbot for commit 855a54e. Bugbot is set up for automated code reviews on this repo. Configure here.