From 888c87f984610db1bb7abff8d1a98d2f2945b12f Mon Sep 17 00:00:00 2001 From: agent_vscode Date: Tue, 7 Jul 2026 04:19:26 +0300 Subject: [PATCH] feat(config): lower MCP timeouts and raise JSON body limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce the default external-MCP silence timeout from 5 min to 1 min and the overall call timeout from 15 min to 2 min. Update related tests and comments. Increase Fastify's JSON body limit to 25 MiB (configurable via HTTP_JSON_BODY_LIMIT) to accommodate large AI‑chat payloads. BREAKING CHANGE: shorter MCP timeouts may abort long‑running tool calls that previously succeeded with the older defaults. --- .env.example | 18 +++++++++++++----- .../external-mcp/mcp-call-timeout.spec.ts | 16 ++++++++-------- .../external-mcp/mcp-clients.service.ts | 6 +++--- .../src/integrations/ai/ai-streaming-fetch.ts | 14 +++++++------- apps/server/src/main.ts | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 34c1a940..f5cf5c63 100644 --- a/.env.example +++ b/.env.example @@ -191,16 +191,24 @@ MCP_DOCMOST_PASSWORD= # Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider). # Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in -# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent +# ~1 min instead of 15. Note it also cuts a legitimately long but byte-silent # single tool call (a slow crawl that emits nothing until done) and an SSE -# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min). -# AI_MCP_STREAM_TIMEOUT_MS=300000 +# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min). +# AI_MCP_STREAM_TIMEOUT_MS=60000 # Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not # transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle) # but never returns a result — which the silence timeout above never breaks. -# Default 900000 (15 min). -# AI_MCP_CALL_TIMEOUT_MS=900000 +# Default 120000 (2 min). +# AI_MCP_CALL_TIMEOUT_MS=120000 + +# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too +# small for a long AI-chat research turn: the client resends the FULL message +# history (every tool call + search result) on each turn, so a deep conversation's +# POST to /api/ai-chat/stream can be several MB and would otherwise be rejected +# with FST_ERR_CTP_BODY_TOO_LARGE (413). Does NOT affect multipart file uploads +# (see FILE_UPLOAD_SIZE_LIMIT). Default 26214400 (25 MiB). +# HTTP_JSON_BODY_LIMIT=26214400 # Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees # a compact and only CORE tools + a loadTools meta-tool are active diff --git a/apps/server/src/core/ai-chat/external-mcp/mcp-call-timeout.spec.ts b/apps/server/src/core/ai-chat/external-mcp/mcp-call-timeout.spec.ts index d5880ae0..8dc35f0f 100644 --- a/apps/server/src/core/ai-chat/external-mcp/mcp-call-timeout.spec.ts +++ b/apps/server/src/core/ai-chat/external-mcp/mcp-call-timeout.spec.ts @@ -181,25 +181,25 @@ describe('mcp timeout env helpers', () => { else process.env.AI_MCP_CALL_TIMEOUT_MS = ORIG_CALL; }); - it('mcpStreamTimeoutMs defaults to 5 min and honors a positive override', () => { + it('mcpStreamTimeoutMs defaults to 1 min and honors a positive override', () => { delete process.env.AI_MCP_STREAM_TIMEOUT_MS; - expect(mcpStreamTimeoutMs()).toBe(300_000); - process.env.AI_MCP_STREAM_TIMEOUT_MS = '60000'; expect(mcpStreamTimeoutMs()).toBe(60_000); + process.env.AI_MCP_STREAM_TIMEOUT_MS = '90000'; + expect(mcpStreamTimeoutMs()).toBe(90_000); for (const bad of ['0', '-1', 'x', '']) { process.env.AI_MCP_STREAM_TIMEOUT_MS = bad; - expect(mcpStreamTimeoutMs()).toBe(300_000); + expect(mcpStreamTimeoutMs()).toBe(60_000); } }); - it('mcpCallTimeoutMs defaults to 15 min and honors a positive override', () => { + it('mcpCallTimeoutMs defaults to 2 min and honors a positive override', () => { delete process.env.AI_MCP_CALL_TIMEOUT_MS; - expect(mcpCallTimeoutMs()).toBe(900_000); - process.env.AI_MCP_CALL_TIMEOUT_MS = '120000'; expect(mcpCallTimeoutMs()).toBe(120_000); + process.env.AI_MCP_CALL_TIMEOUT_MS = '180000'; + expect(mcpCallTimeoutMs()).toBe(180_000); for (const bad of ['0', '-1', 'x', '']) { process.env.AI_MCP_CALL_TIMEOUT_MS = bad; - expect(mcpCallTimeoutMs()).toBe(900_000); + expect(mcpCallTimeoutMs()).toBe(120_000); } }); }); diff --git a/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts index 53b479db..762846f2 100644 --- a/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts +++ b/apps/server/src/core/ai-chat/external-mcp/mcp-clients.service.ts @@ -523,12 +523,12 @@ export function validateResolvedAddresses(addrs: readonly LookupAddress[]): { */ function buildPinnedDispatcher(): Agent { // External-MCP traffic uses a DEDICATED, shorter silence timeout - // (`AI_MCP_STREAM_TIMEOUT_MS`, default 5 min) — deliberately tighter than the + // (`AI_MCP_STREAM_TIMEOUT_MS`, default 1 min) — deliberately tighter than the // chat provider's 15-min `streamTimeoutMs()` — so a byte-silent/hung MCP - // upstream is broken in ~5 min instead of 15. We keep the keep-alive options + // upstream is broken in ~1 min instead of 15. We keep the keep-alive options // from `streamingDispatcherOptions()` but OVERRIDE headers/body timeouts. // Accepted trade-off: a legitimately long but byte-silent single tool call, - // and an SSE transport idling >5 min BETWEEN tool calls, are also cut here; the + // and an SSE transport idling >1 min BETWEEN tool calls, are also cut here; the // per-call total cap (wrapToolsWithCallTimeout, `AI_MCP_CALL_TIMEOUT_MS`) is the // complementary guard for chatty-but-stuck calls that keep the socket warm yet // never return. diff --git a/apps/server/src/integrations/ai/ai-streaming-fetch.ts b/apps/server/src/integrations/ai/ai-streaming-fetch.ts index 725c71f5..b4a31723 100644 --- a/apps/server/src/integrations/ai/ai-streaming-fetch.ts +++ b/apps/server/src/integrations/ai/ai-streaming-fetch.ts @@ -123,19 +123,19 @@ export function streamKeepAliveMs(): number { return positiveEnv('AI_STREAM_KEEPALIVE_MS', DEFAULT_STREAM_KEEPALIVE_MS); } -/** Default SILENCE timeout for EXTERNAL-MCP transport (5 min). */ -const DEFAULT_MCP_STREAM_TIMEOUT_MS = 300_000; +/** Default SILENCE timeout for EXTERNAL-MCP transport (1 min). */ +const DEFAULT_MCP_STREAM_TIMEOUT_MS = 60_000; -/** Default total wall-clock cap for ONE external MCP tool call (15 min). */ -const DEFAULT_MCP_CALL_TIMEOUT_MS = 900_000; +/** Default total wall-clock cap for ONE external MCP tool call (2 min). */ +const DEFAULT_MCP_CALL_TIMEOUT_MS = 120_000; /** * SILENCE timeout (ms) for EXTERNAL-MCP transport ONLY. Override with * `AI_MCP_STREAM_TIMEOUT_MS`; a missing/invalid/non-positive value falls back to - * {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (5 min). + * {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (1 min). * * Deliberately tighter than the chat provider's {@link streamTimeoutMs} (15 min) - * so a byte-silent/hung MCP upstream is broken in ~5 min instead of 15. This is + * so a byte-silent/hung MCP upstream is broken in ~1 min instead of 15. This is * the undici `headersTimeout`/`bodyTimeout` for the external-MCP dispatcher only * — it must NOT change the chat provider, which legitimately needs 15 min between * reasoning chunks (#175). @@ -153,7 +153,7 @@ export function mcpStreamTimeoutMs(): number { /** * Total wall-clock cap (ms) for ONE external MCP tool call — APP-LEVEL, not * transport. Override with `AI_MCP_CALL_TIMEOUT_MS`; a missing/invalid/ - * non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (15 min). + * non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (2 min). * * Catches a tool that keeps the connection warm (SSE heartbeats / trickle) but * never returns a result — which the transport silence timeout diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 04df81d8..12ab7ca4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -21,10 +21,24 @@ import { recordHttpResponse } from './integrations/metrics/http-metrics.hook'; import { startMetricsServer } from './integrations/metrics/metrics.server'; async function bootstrap() { + // Fastify JSON body cap. Fastify defaults to 1 MiB, which a long AI-chat + // research turn exceeds: the client resends the FULL message history (every + // tool call + search result) on each turn, so a deep conversation's POST to + // /api/ai-chat/stream can be several MB and would otherwise be rejected with + // FST_ERR_CTP_BODY_TOO_LARGE (413). Raise the cap; override with + // HTTP_JSON_BODY_LIMIT (bytes). A missing/invalid/non-positive value keeps the + // 25 MiB default. Multipart uploads are unaffected (their own @fastify/multipart + // limits apply); this only bounds JSON/urlencoded request bodies. + const bodyLimitEnv = Number(process.env.HTTP_JSON_BODY_LIMIT); + const bodyLimit = + Number.isFinite(bodyLimitEnv) && bodyLimitEnv > 0 + ? bodyLimitEnv + : 25 * 1024 * 1024; const app = await NestFactory.create( AppModule, new FastifyAdapter({ trustProxy: resolveTrustProxy(process.env.TRUST_PROXY), + bodyLimit, routerOptions: { maxParamLength: 1000, ignoreTrailingSlash: true,