Skip to content

fix(den): restore the external MCP tool-call budget on the live client path - #3122

Merged
benjaminshafii merged 1 commit into
devfrom
fix/external-mcp-tool-call-budget
Jul 25, 2026
Merged

fix(den): restore the external MCP tool-call budget on the live client path#3122
benjaminshafii merged 1 commit into
devfrom
fix/external-mcp-tool-call-budget

Conversation

@benjaminshafii

Copy link
Copy Markdown
Member

What was broken

An external MCP capability called through execute_capability got 30 seconds for the entire session — connect + OAuth refresh + initialize + tools/call all shared one budget, so the provider's actual work got whatever was left. Then executeExternalCapability spent that budget twice: once for schema-digest discovery, once for the call, each paying its own handshake.

Real failure that started this, reproduced twice against deployed den-api at ~31s (30s + a full second discovery session):

connection failed — The capability did not finish within the time OpenWork allows a single tool call. Retry the capability, and reduce provider latency for this tool if it keeps running past the bounded deadline.

What introduced it

When PR Effect
2026-07-12 #2694 4c85588e6 Added the package-first enterprise MCP client behind env.enterpriseMcpClientEnabled. Shipped the Math.min(operationTimeoutMs, …) clamp, resetTimeoutOnProgress: false, and an adapter tool-call path that forwards no lifecycle deadline. Latent — flag was off.
2026-07-14 #2750 152a0c229 "extend external MCP tool timeout" raised the tool budget to 120s/150s in external-mcp-client.ts. Correct and effective at the time, because the legacy client was still selected.
2026-07-15 #2810 2b72a99aa The regression. "standardize external MCP OAuth on enterprise client" deleted the legacy runtime and the flag. callExternalMcpTool now resolves to the adapter, which forwards no deadline — so the package's 30s default took over and #2750's fix silently stopped applying. Its 120s/150s constants became dead code, as did the only test asserting them.
2026-07-25 #3088 218a13cb3 Made the abort attributable to OpenWork. Before this it surfaced as an unrecognized provider JSON-RPC -32001, i.e. blamed on the provider — which is why this only became visible ten days after it broke.

Compounding it, the Math.min(operationTimeoutMs, …) clamp at enterprise-mcp-client.ts:209 meant that even injecting the intended 150s deadline would have been clipped back to 30s, so the obvious one-line fix would not have worked.

The change

  • An injected lifecycle deadline is now authoritative in both directions; operationTimeoutMs demotes to the fallback default used only when no deadline is injected.
  • The adapter forwards the lifecycle deadline on the tool-call path, mirroring listExternalMcpTools beside it, and sets the 120s per-request budget.
  • executeExternalCapability creates one deadline and shares it between schema discovery and the call, so the handshake and the budget are paid once instead of twice.
  • Per-request timeout is now strictly below the absolute maxTotalTimeout, with resetTimeoutOnProgress: true. We were stricter than our own caller — opencode uses true; we used false. The SDK only attaches a progressToken when an onprogress handler is present (shared/protocol.js:643-649), so the flag alone would have been inert; a handler is included.

Net effect: our ceiling becomes a backstop and the calling harness decides. opencode's own per-tool-call timeout (mcp[name].timeoutexperimental.mcp_timeout → 60s) is now the binding constraint, which is the intended design.

30 lines of production code across 3 files.

Tests run

Command Result
pnpm --filter @openwork/enterprise-mcp-client test 61 pass / 0 fail
pnpm --filter @openwork/enterprise-mcp-client typecheck clean
pnpm exec tsc -p ee/apps/den-api/tsconfig.json --noEmit clean
bun test ee/apps/den-api/test/external-capabilities-search-divergence.test.ts 23 pass / 0 fail (real DB + real MCP servers)
bun test — search-divergence + adapter-budget + agent-timeouts + diagnostics 119 pass / 0 fail

Every new test was confirmed to fail on the bug and pass on the fix, by reverting each production file individually:

  • revert the adapter → expect(maxTotalTimeout).toBeGreaterThanOrEqual(149000) got 30000
  • revert external-capabilities.ts → same, 30000, from the shared-deadline test
  • revert the package → the two progress/lifecycle tests fail

New coverage: a live-path test capturing the real RequestOptions the MCP SDK receives via callExternalMcpTool (maxTotalTimeout ≈ 150s, timeout ≥ 120s, timeout < maxTotalTimeout, resetTimeoutOnProgress, onprogress), plus a shared-budget test asserting the tool call's window is strictly smaller than discovery's — one draining budget rather than two fresh ones. The previous 120s assertion tested the bypassed file and passed throughout the outage.

Known caveats, stated honestly

No fraimz. This change is entirely inside the cloud service. The observable win — a slow capability completing instead of dying at 30s — can only be seen against a deployed den-api; the desktop talks to deployed cloud, which still runs the old code. I am not claiming an end-to-end proof I do not have. Reviewer repro after deploy: call any slow external MCP capability that previously died at ~31s.

One test reds in a full-directory run, bun test ee/apps/den-api/test: execute_capability shares one external MCP lifecycle budget…. It passes in isolation and in every targeted combination. Cause is pre-existing and unrelated: marketplace-cloud-readiness.test.ts:67 registers a process-global mock.module that throws on any external MCP runtime call, and mock.module has no per-file scoping — so 18 of the 22 tests already in that file also fail in the base directory run (base 166 fail / branch 167). My test becomes victim 19. Fixing that global mock is separate cleanup. CI does not run den-api tests.

Worst case is deliberately slower. A hanging provider used to be cut at 30s + 30s; there is now one 150s bound with the existing 180s execute_capability race as backstop, and opencode cutting at 60s in practice.

Also affects search_capabilities: its probes were silently clamped to 30s each by the same bug and can now use the shared 45s window. The absolute 45s fan-out bound is unchanged, so total search time is not affected.

…t path

An external MCP capability got 30s for the whole session -- connect, OAuth
refresh, initialize and tools/call shared one budget -- and execute_capability
spent that budget twice, once for schema-digest discovery and once for the
call. Slow providers died at ~31s.

The 120s/150s tool budget from #2750 was never reached. #2810 deleted the
legacy runtime, so callExternalMcpTool resolves to the enterprise client, and
the adapter's tool-call path forwarded no lifecycle deadline -- unlike
listExternalMcpTools beside it -- leaving the package's 30s default in charge.
A Math.min(operationTimeoutMs, ...) clamp meant injecting the longer deadline
would have been clipped back to 30s anyway. The only test asserting 120s
exercised the bypassed file.

Make an injected lifecycle deadline authoritative in both directions and
demote operationTimeoutMs to the fallback default. Forward the deadline on the
tool-call path, and share one deadline between discovery and the call so the
handshake and the budget are paid once. Split the per-request timeout from the
absolute bound and stop cancelling a provider that is still reporting
progress, matching the calling harness instead of being stricter than it; the
SDK only attaches a progressToken when an onprogress handler is present, so
the flag alone was inert.
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jul 25, 2026 3:40pm
openwork-den Ready Ready Preview, Comment Jul 25, 2026 3:40pm
openwork-den-worker-proxy Ready Ready Preview, Comment Jul 25, 2026 3:40pm
openwork-landing Ready Ready Preview, Comment, Open in v0 Jul 25, 2026 3:40pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
openwork-diagnostics Skipped Skipped Jul 25, 2026 3:40pm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant