Skip to content

fix: MCP client poison recovery and per-tool-call timeout#53

Merged
TGPSKI merged 1 commit into
mainfrom
fix/mcp-client-resilience
Jul 17, 2026
Merged

fix: MCP client poison recovery and per-tool-call timeout#53
TGPSKI merged 1 commit into
mainfrom
fix/mcp-client-resilience

Conversation

@TGPSKI

@TGPSKI TGPSKI commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

Implements plans/02-mcp-client-resilience.md (Steps 1 & 2 — the runtime fixes). Steps 3 & 4 are config-only changes in the separate tannery/sh-web workspace repo and are out of scope for this PR.

Serve-log evidence from 2026-07-15/16 showed one slow tool call (github-analytics-auto, an 89-repo fetch) hit the 900s run deadline mid-read. That poisoned the shared MCP client, and because the registry holds one client per server for the process lifetime with no recovery path, every subsequent tool call from every agent failed with client poisoned by prior read timeout until a manual restart — a 7.5-hour outage.

Changes

Poison recovery (internal/mcp/registry.go, internal/tool/executor.go)

  • Registry.Recreate(ctx, name, stale) closes the poisoned client and starts a fresh one, swapping it into the registry under a mutex. Serialized so concurrent agents don't race multiple restarts; if another caller already recreated, the fresh client is reused. The clients map is now guarded for concurrent Get/Recreate.
  • execMCP recreates the client once and retries on mcp.ErrPoisoned, so a poisoned transport self-heals on the next call instead of requiring a leather serve restart. The poison mechanism itself is unchanged — it correctly protects the corrupted transport.

Per-tool-call timeout (internal/runner/runner.go, config plumbing)

  • New tool_timeout config: global default 600s (env LEATHER_TOOL_TIMEOUT, flag --tool-timeout), overridable per agent via front-matter / lifecycle tool_timeout:.
  • Each tool call is wrapped in a child context.WithTimeout, distinct from the run-level timeout budget. A tool exceeding it fails a single call cleanly (tool X exceeded tool_timeout …) while the run continues. 0 disables the per-tool deadline.
  • Surfaced in leather doctor.

Tests

  • TestRegistry_RecreateAfterPoison — full poison → ErrPoisoned → recreate → success cycle.
  • TestRegistry_RecreateConcurrent — 8 concurrent callers trigger exactly one restart.
  • TestExecMCP_RecoversFromPoison — executor recovery path end-to-end.
  • TestRunner_PerToolTimeoutFailsOneCall — per-tool timeout bounds one call, run continues, run budget not consumed.
  • TestParseFrontMatter_Fulltool_timeout: front-matter parsing.

Validation

  • go build ./..., go vet ./..., gofmt, golangci-lint run (0 issues)
  • Full go test ./... passes; -race clean on internal/mcp and internal/tool
  • leather doctor shows tool_timeout 10m0s default

One tool call that consumed the run budget and expired mid-read poisoned
the shared MCP client, and nothing ever recovered it — the registry held
one client per server for the process lifetime, so every subsequent tool
call from every agent short-circuited with ErrPoisoned until a manual
`leather serve` restart (a 7.5-hour production outage).

Two fixes:

- Poison recovery: Registry.Recreate closes the poisoned client and starts
  a fresh one under a mutex (serialized so concurrent callers don't race
  multiple restarts). On mcp.ErrPoisoned the executor recreates once and
  retries, so a poisoned transport self-heals on the next call.

- Per-tool-call timeout: a single run-level timeout governed every LLM call
  AND every tool call, so a slow tool ate the whole run budget and expired
  the shared read. New `tool_timeout` config (global default 600s; env
  LEATHER_TOOL_TIMEOUT; flag --tool-timeout), overridable per agent via
  front-matter / lifecycle `tool_timeout:`, wraps each tool call in a child
  deadline. A tool exceeding it fails one call cleanly while the run
  continues; 0 disables it.

Tests cover the poison→recreate→success cycle (incl. concurrent recreation),
execMCP recovery, and the per-tool timeout bounding a single call without
consuming the run budget.
@TGPSKI
TGPSKI merged commit 3b6f6d1 into main Jul 17, 2026
3 checks passed
@TGPSKI
TGPSKI deleted the fix/mcp-client-resilience branch July 17, 2026 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant