Skip to content

Commit 9401e86

Browse files
committed
Align managed-auth app completion with wait guards
1 parent 1dec6d2 commit 9401e86

6 files changed

Lines changed: 145 additions & 16 deletions

File tree

src/lib/mcp/apps/managed-auth-entry.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ type BeginResult = {
6161
};
6262
isError?: boolean;
6363
};
64+
type WaitToolResult = {
65+
structuredContent?: {
66+
state?: "authenticated" | "failed" | "pending";
67+
};
68+
};
6469

6570
let nextRequestId = 1;
6671
const pendingRequests = new Map<number, PendingRequest>();
@@ -118,11 +123,11 @@ function sendNotification(method: string, params: JsonObject) {
118123
postToHost({ jsonrpc: "2.0", method, params });
119124
}
120125

121-
function callTool(name: string, args: JsonObject): Promise<BeginResult> {
126+
function callTool<T = BeginResult>(name: string, args: JsonObject): Promise<T> {
122127
return sendRequest("tools/call", {
123128
name,
124129
arguments: args,
125-
}) as Promise<BeginResult>;
130+
}) as Promise<T>;
126131
}
127132

128133
function applyHostContext(context: JsonObject | undefined) {
@@ -247,6 +252,37 @@ function sanitizeBeginArguments(input: JsonObject): JsonObject {
247252
);
248253
}
249254

255+
function waitArgumentsFromLauncher(
256+
content: JsonObject | undefined,
257+
): JsonObject | null {
258+
const nextAction = content?.next_action as
259+
| {
260+
tool?: unknown;
261+
arguments?: JsonObject;
262+
}
263+
| undefined;
264+
if (nextAction?.tool !== "manage_auth_connections" || !nextAction.arguments) {
265+
return null;
266+
}
267+
const args = nextAction.arguments;
268+
if (args.action !== "wait") return null;
269+
const allowed = [
270+
"action",
271+
"id",
272+
"domain_filter",
273+
"profile_name",
274+
"wait_seconds",
275+
"required_flow_type",
276+
"previous_flow_expires_at",
277+
"previous_flow_event_id",
278+
];
279+
return Object.fromEntries(
280+
allowed
281+
.filter((key) => args[key] !== undefined)
282+
.map((key) => [key, args[key]]),
283+
);
284+
}
285+
250286
function ManagedAuthApp() {
251287
const launcher = useLauncherData();
252288
const [beginResult, setBeginResult] = useState<BeginResult | null>(null);
@@ -266,6 +302,7 @@ function ManagedAuthApp() {
266302
const launcherContent = launcher.result?.structuredContent as
267303
| {
268304
connection?: { domain?: string; profile_name?: string };
305+
next_action?: { tool?: string; arguments?: JsonObject };
269306
}
270307
| undefined;
271308
const targetDomain =
@@ -277,6 +314,9 @@ function ManagedAuthApp() {
277314
const privateAuth =
278315
beginResult?._meta?.auth_login ??
279316
beginResult?.structuredContent?.app_private;
317+
const waitArguments = waitArgumentsFromLauncher(
318+
launcher.result?.structuredContent as JsonObject | undefined,
319+
);
280320

281321
const appearance = useMemo(
282322
() => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }),
@@ -450,6 +490,24 @@ function ManagedAuthApp() {
450490
current.flow_status === "SUCCESS" &&
451491
current.status === "AUTHENTICATED"
452492
) {
493+
if (waitArguments) {
494+
const wait = await callTool<WaitToolResult>(
495+
"manage_auth_connections",
496+
waitArguments,
497+
);
498+
const waitState = wait.structuredContent?.state;
499+
if (waitState === "authenticated") {
500+
finish("success");
501+
return;
502+
}
503+
if (waitState === "failed") {
504+
finish("failure");
505+
return;
506+
}
507+
setStatusText("Secure login is still in progress…");
508+
pollTimer.current = window.setTimeout(checkStatus, 2000);
509+
return;
510+
}
453511
finish("success");
454512
return;
455513
}

src/lib/mcp/tools/auth-connections.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,42 @@ describe("managed-auth wait", () => {
516516
expect(result.state).toBe("authenticated");
517517
});
518518

519+
test("null timeline baseline does not treat old success as a new flow", async () => {
520+
const stale = connection({
521+
status: "AUTHENTICATED",
522+
flow_status: "SUCCESS",
523+
flow_type: "REAUTH",
524+
flow_expires_at: null,
525+
});
526+
const client = {
527+
auth: {
528+
connections: {
529+
retrieve: async () => stale,
530+
timeline: async () => ({
531+
getPaginatedItems: () => [
532+
{
533+
id: "flow_old",
534+
type: "reauth",
535+
status: "SUCCESS",
536+
timestamp: "2026-01-01T00:00:00Z",
537+
},
538+
],
539+
}),
540+
},
541+
},
542+
} as unknown as KernelClient;
543+
const result = await waitForAuthConnection(
544+
client,
545+
{
546+
connectionId: stale.id,
547+
previousFlowExpiresAt: null,
548+
previousFlowEventId: null,
549+
},
550+
{ timeoutMs: 0 },
551+
);
552+
expect(result.state).toBe("pending");
553+
});
554+
519555
test("baseline-guarded wait stays pending on the stale pre-flow success", async () => {
520556
const stale = connection({
521557
status: "AUTHENTICATED",

src/lib/mcp/tools/auth-connections.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export function registerAuthConnectionTools(server: McpServer) {
151151
...(params.previous_flow_expires_at !== undefined && {
152152
previousFlowExpiresAt: params.previous_flow_expires_at,
153153
}),
154-
...(params.previous_flow_event_id !== undefined && {
154+
...(params.previous_flow_event_id != null && {
155155
previousFlowEventId: params.previous_flow_event_id,
156156
}),
157157
},

src/lib/mcp/tools/auth-login-app.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,47 @@ describe("managed-auth MCP App registration", () => {
383383
}
384384
});
385385

386+
test("reauth launcher omits timeline baseline when no prior flow event exists", async () => {
387+
kernelClientFactory = () => ({
388+
auth: {
389+
connections: {
390+
retrieve: async () => ({
391+
id: "conn_1",
392+
domain: "example.com",
393+
profile_name: "work",
394+
status: "AUTHENTICATED",
395+
flow_status: "SUCCESS",
396+
flow_type: "LOGIN",
397+
flow_expires_at: null,
398+
}),
399+
timeline: async () => ({
400+
getPaginatedItems: () => [],
401+
}),
402+
},
403+
},
404+
});
405+
try {
406+
const { tools } = captureRegistration();
407+
const result = await tools
408+
.get("open_auth_login")!
409+
.handler(
410+
{ mode: "reauth", connection_id: "conn_1", text_only: false },
411+
{ authInfo: { token: "unused-api-key" } },
412+
);
413+
expect(result.structuredContent.next_action.arguments).toEqual({
414+
action: "wait",
415+
id: "conn_1",
416+
wait_seconds: 25,
417+
previous_flow_expires_at: null,
418+
});
419+
expect(
420+
JSON.stringify(result.structuredContent.next_action.arguments),
421+
).not.toContain("previous_flow_event_id");
422+
} finally {
423+
resetKernelClientFactory();
424+
}
425+
});
426+
386427
test("reauth launcher observing a live flow emits no baseline guard", async () => {
387428
kernelClientFactory = () => ({
388429
auth: {

src/lib/mcp/tools/auth-login-app.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,14 @@ const authLoginInputSchema = {
5555
async function latestAuthFlowEventId(
5656
client: KernelClient,
5757
connectionId: string,
58-
): Promise<string | null | undefined> {
58+
): Promise<string | undefined> {
5959
try {
6060
const page = await client.auth.connections.timeline(connectionId, {
6161
limit: 10,
6262
});
63-
return (
64-
page
65-
.getPaginatedItems()
66-
.find((event) => event.type === "login" || event.type === "reauth")
67-
?.id ?? null
68-
);
63+
return page
64+
.getPaginatedItems()
65+
.find((event) => event.type === "login" || event.type === "reauth")?.id;
6966
} catch {
7067
return undefined;
7168
}

src/lib/mcp/tools/managed-auth-state.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,7 @@ export async function waitForAuthConnection(
240240
if (connection) {
241241
latest = toSafeAuthConnection(connection);
242242
if (hasLiveAuthFlow(latest)) observedLiveFlow = true;
243-
if (
244-
selector.previousFlowEventId !== undefined &&
245-
selector.connectionId
246-
) {
243+
if (selector.previousFlowEventId != null && selector.connectionId) {
247244
try {
248245
const event = await latestAuthFlowEvent(
249246
client,
@@ -271,7 +268,7 @@ export async function waitForAuthConnection(
271268
const flowGuarded =
272269
selector.requiredFlowType !== undefined ||
273270
selector.previousFlowExpiresAt !== undefined ||
274-
selector.previousFlowEventId !== undefined;
271+
selector.previousFlowEventId != null;
275272
const flowFailed =
276273
observedNewFlowFailed ||
277274
latest.flow_status === "FAILED" ||
@@ -294,7 +291,7 @@ export async function waitForAuthConnection(
294291
(selector.previousFlowExpiresAt === undefined ||
295292
latest.flow_expires_at !== selector.previousFlowExpiresAt ||
296293
observedLiveFlow) &&
297-
(selector.previousFlowEventId === undefined || observedNewFlow));
294+
(selector.previousFlowEventId == null || observedNewFlow));
298295
// AUTHENTICATED with a live in-progress flow means a (re-)auth is
299296
// still running: report pending instead of the stale pre-flow state.
300297
if (

0 commit comments

Comments
 (0)