From 3cf106152b8f5494fcb224f333c49db606eedf07 Mon Sep 17 00:00:00 2001 From: 7Sageer <125936732+7Sageer@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:32:20 +0800 Subject: [PATCH] fix(mcp): abort the auth::complete long poll on client disconnect --- packages/kap-server/src/routes/v2/mcp.ts | 20 ++++++++++-- packages/kap-server/src/start.ts | 1 + packages/kap-server/test/helpers/auth.ts | 1 + packages/kap-server/test/v2Mcp.test.ts | 41 ++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/routes/v2/mcp.ts b/packages/kap-server/src/routes/v2/mcp.ts index c597161a8..7fa3496ae 100644 --- a/packages/kap-server/src/routes/v2/mcp.ts +++ b/packages/kap-server/src/routes/v2/mcp.ts @@ -1,3 +1,5 @@ +import type { ServerResponse } from 'node:http'; + import { ErrorCodes, IConfigService, @@ -97,7 +99,12 @@ const inspectServersBodySchema = z.object({ const authCompleteBodySchema = z.object({ flowId: z.string().min(1), - timeoutMs: z.number().int().min(1).optional(), + timeoutMs: z + .number() + .int() + .min(1) + .max(2 ** 31 - 1) + .optional(), }); const authCancelBodySchema = z.object({ flowId: z.string().min(1) }); @@ -484,11 +491,20 @@ export function registerV2McpRoutes(app: V2McpRouteHost, core: Scope): void { tags: ['v2-mcp'], }, async (req, reply) => { + const { raw } = reply as unknown as { raw: ServerResponse }; + const disconnect = new AbortController(); + const onClose = (): void => { + if (raw.writableFinished) return; + disconnect.abort(); + }; + raw.once('close', onClose); try { - await management().completeServerAuth(req.body); + await management().completeServerAuth(req.body, { signal: disconnect.signal }); reply.send(okEnvelope(null, req.id)); } catch (err) { sendMappedError(reply, req.id, err); + } finally { + raw.off('close', onClose); } }, ); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 2e6c533e1..ee97dff52 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -301,6 +301,7 @@ export async function startServer(opts: ServerStartOptions): Promise resolveRequestId(req.headers), }) as unknown as FastifyInstance; + app.server.requestTimeout = 0; registerRequestLogging(app); app.setValidatorCompiler(() => () => true); app.setSerializerCompiler(() => (data) => JSON.stringify(data)); diff --git a/packages/kap-server/test/helpers/auth.ts b/packages/kap-server/test/helpers/auth.ts index fa1fd62e5..8b7d0b9aa 100644 --- a/packages/kap-server/test/helpers/auth.ts +++ b/packages/kap-server/test/helpers/auth.ts @@ -6,6 +6,7 @@ interface FetchOptions { readonly method?: string; readonly headers?: HeaderMap; readonly body?: string; + readonly signal?: AbortSignal; } export function bearerToken(server: RunningServer): string { diff --git a/packages/kap-server/test/v2Mcp.test.ts b/packages/kap-server/test/v2Mcp.test.ts index 477970738..85b4a5311 100644 --- a/packages/kap-server/test/v2Mcp.test.ts +++ b/packages/kap-server/test/v2Mcp.test.ts @@ -461,5 +461,46 @@ describe('server /api/v2/mcp', () => { expect(reset.body).toMatchObject({ code: 0, data: null }); expect(stub.state.lastResetLocator).toEqual({ source: 'plugin', pluginId: 'p', serverName: 's' }); }); + + it('rejects an overflowing auth:complete timeoutMs with 40001', async () => { + const stub = makeMcpStub(); + await boot(stub); + + const res = await call('POST', '/api/v2/mcp/auth:complete', { + flowId: 'flow-1', + timeoutMs: 2 ** 31, + }); + + expect(res.body.code).toBe(40001); + expect(stub.calls).toEqual([]); + }); + + it('aborts the engine wait when the client disconnects mid-complete', async () => { + const stub = makeMcpStub(); + let seenSignal: AbortSignal | undefined; + let reached = false; + stub.service.completeServerAuth = async (_handle, options) => { + seenSignal = options?.signal; + reached = true; + await new Promise((resolve) => { + options?.signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + }; + await boot(stub); + + const controller = new AbortController(); + const pending = authedFetch(server as RunningServer, base, '/api/v2/mcp/auth:complete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ flowId: 'flow-1' }), + signal: controller.signal, + }); + await vi.waitFor(() => expect(reached).toBe(true)); + + controller.abort(); + + await expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(seenSignal?.aborted).toBe(true)); + }); }); });