From bf4ec99d3d1778a99fe4d8a67bd244e4bde11bb5 Mon Sep 17 00:00:00 2001 From: zouying Date: Tue, 4 Aug 2026 19:41:27 +0800 Subject: [PATCH 1/2] feat(mcp): carry an absolute expiresAt on the OAuth authorization-url update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authenticate flow waits for the OAuth callback with a fixed budget, but the authorization-url tool update surfaced to embedding hosts did not say when that window ends — hosts had to hardcode a mirror of the 15-minute constant to render countdowns. Include the absolute deadline (now + effective wait timeout) in the update payload for v1 and v2. Resolve #2607 --- .../agent-core-v2/src/agent/mcp/tools/auth.ts | 5 ++++- .../test/agent/mcp/tools/auth.test.ts | 18 +++++++++++------- packages/agent-core/src/mcp/auth-tool.ts | 6 +++++- packages/agent-core/test/mcp/auth-tool.test.ts | 18 +++++++++++------- packages/protocol/src/events.ts | 6 ++++++ 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts index f8b1a92e7d..97eea0af04 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/auth.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/auth.ts @@ -40,6 +40,7 @@ export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_ export interface McpOAuthAuthorizationUrlUpdateData { readonly serverName: string; readonly authorizationUrl: string; + readonly expiresAt?: number; } const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000; @@ -103,9 +104,11 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable } const urlText = flow.authorizationUrl.toString(); + const waitTimeoutMs = timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; const customData: McpOAuthAuthorizationUrlUpdateData = { serverName, authorizationUrl: urlText, + expiresAt: Date.now() + waitTimeoutMs, }; onUpdate?.({ kind: 'custom', @@ -122,7 +125,7 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable }); try { - await flow.complete({ signal, timeoutMs: timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS }); + await flow.complete({ signal, timeoutMs: waitTimeoutMs }); } catch (error) { return errorResult(serverName, error, urlText); } diff --git a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts index 7b07717e97..2a49d89b0a 100644 --- a/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts @@ -62,14 +62,18 @@ describe('createMcpAuthTool', () => { expect(final.output).toMatch(/authenticated successfully/); expect(reconnectCalls).toBe(1); expect(updates.some((u) => u.text?.includes('https://example.com/authorize'))).toBe(true); - expect(updates).toContainEqual({ - kind: 'custom', - customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, - customData: { - serverName: 'notion', - authorizationUrl: 'https://example.com/authorize?state=abc', - }, + const authUpdate = updates.find( + (u) => u.kind === 'custom' && u.customKind === MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + ); + expect(authUpdate?.customData).toMatchObject({ + serverName: 'notion', + authorizationUrl: 'https://example.com/authorize?state=abc', }); + // The deadline is absolute (now + wait timeout), so hosts never mirror + // the engine-side constant. + const { expiresAt } = authUpdate?.customData as { expiresAt?: number }; + expect(expiresAt).toBeGreaterThan(Date.now()); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 15 * 60 * 1000); }); it('falls through to reconnect when the provider reports already-authorized', async () => { diff --git a/packages/agent-core/src/mcp/auth-tool.ts b/packages/agent-core/src/mcp/auth-tool.ts index 414bd0b40a..d921809fa7 100644 --- a/packages/agent-core/src/mcp/auth-tool.ts +++ b/packages/agent-core/src/mcp/auth-tool.ts @@ -113,9 +113,13 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable } const urlText = flow.authorizationUrl.toString(); + const waitTimeoutMs = timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; const customData: McpOAuthAuthorizationUrlUpdateData = { serverName, authorizationUrl: urlText, + // Absolute deadline of the pending flow, so hosts can render countdown + // or expiry states without mirroring DEFAULT_AUTH_TIMEOUT_MS. + expiresAt: Date.now() + waitTimeoutMs, }; onUpdate?.({ kind: 'custom', @@ -132,7 +136,7 @@ export function createMcpAuthTool(options: CreateMcpAuthToolOptions): Executable }); try { - await flow.complete({ signal, timeoutMs: timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS }); + await flow.complete({ signal, timeoutMs: waitTimeoutMs }); } catch (error) { return errorResult(serverName, error, urlText); } diff --git a/packages/agent-core/test/mcp/auth-tool.test.ts b/packages/agent-core/test/mcp/auth-tool.test.ts index 0025a4ab30..824377d4b0 100644 --- a/packages/agent-core/test/mcp/auth-tool.test.ts +++ b/packages/agent-core/test/mcp/auth-tool.test.ts @@ -66,14 +66,18 @@ describe('createMcpAuthTool', () => { expect(final.output).toMatch(/authenticated successfully/); expect(reconnectCalls).toBe(1); expect(updates.some((u) => u.text?.includes('https://example.com/authorize'))).toBe(true); - expect(updates).toContainEqual({ - kind: 'custom', - customKind: MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, - customData: { - serverName: 'notion', - authorizationUrl: 'https://example.com/authorize?state=abc', - }, + const authUpdate = updates.find( + (u) => u.kind === 'custom' && u.customKind === MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE, + ); + expect(authUpdate?.customData).toMatchObject({ + serverName: 'notion', + authorizationUrl: 'https://example.com/authorize?state=abc', }); + // The deadline is absolute (now + wait timeout), so hosts never mirror + // the engine-side constant. + const { expiresAt } = authUpdate?.customData as { expiresAt?: number }; + expect(expiresAt).toBeGreaterThan(Date.now()); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 15 * 60 * 1000); }); it('falls through to reconnect when the provider reports already-authorized', async () => { diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 82680cf57e..0746a38143 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -430,6 +430,12 @@ export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_ export interface McpOAuthAuthorizationUrlUpdateData { readonly serverName: string; readonly authorizationUrl: string; + /** + * Epoch-ms instant when the engine stops waiting for the OAuth callback. + * Hosts derive countdowns and expiry states from this value instead of + * mirroring the engine-side timeout constant. + */ + readonly expiresAt?: number; } export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked'; From a46bc7610e49f0bc2530b393a4a31d3eaf609760 Mon Sep 17 00:00:00 2001 From: zouying Date: Tue, 4 Aug 2026 19:56:33 +0800 Subject: [PATCH 2/2] fix(protocol,kap-server): accept expiresAt in the OAuth authorization-url update schemas The zod validators mirrored the pre-expiresAt payload shape and would strip the new field at the kap-server boundary. --- packages/kap-server/src/protocol/events-zod.ts | 1 + packages/protocol/src/events.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 9c5cbba17d..3fa18caa97 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -469,6 +469,7 @@ export const toolUpdateSchema = z.object({ export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ serverName: z.string(), authorizationUrl: z.string(), + expiresAt: z.number().optional(), }) satisfies z.ZodType; export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType; diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 0746a38143..6a90a6cc9b 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -1370,6 +1370,7 @@ export const toolUpdateSchema = z.object({ export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ serverName: z.string(), authorizationUrl: z.string(), + expiresAt: z.number().optional(), }) satisfies z.ZodType; export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType;