Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/agent/mcp/tools/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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);
}
Expand Down
18 changes: 11 additions & 7 deletions packages/agent-core-v2/test/agent/mcp/tools/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-core/src/mcp/auth-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
}
Expand Down
18 changes: 11 additions & 7 deletions packages/agent-core/test/mcp/auth-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/src/protocol/events-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpOAuthAuthorizationUrlUpdateData>;

export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType<TurnEndReason>;
Expand Down
7 changes: 7 additions & 0 deletions packages/protocol/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 430 to +438

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update the OAuth update schema to carry expiresAt

Adding expiresAt to the TypeScript interface is not enough here: the exported mcpOAuthAuthorizationUrlUpdateDataSchema in this file, and the mirrored copy in packages/kap-server/src/protocol/events-zod.ts, still only accept serverName and authorizationUrl. Any consumer that validates the custom update through the official schema will silently strip the new deadline, so hosts never receive the data needed to render the countdown/expired state promised by this change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — the interface change alone would have been stripped at the validation boundary. Fixed in a46bc76: both schema copies (protocol/src/events.ts and the kap-server mirror) now accept expiresAt: z.number().optional().

}

export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked';
Expand Down Expand Up @@ -1364,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<McpOAuthAuthorizationUrlUpdateData>;

export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType<TurnEndReason>;
Expand Down
Loading