Skip to content

docs(specs): Spec 114 typed pending-call outcome after tools/call timeouts - #1536

Open
Dumbris wants to merge 4 commits into
mainfrom
114-pending-call-outcome
Open

Dumbris wants to merge 4 commits into
mainfrom
114-pending-call-outcome

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 6, 2026

Copy link
Copy Markdown
Member

What this covers

Spec 114 (docs only: specs/114-pending-call-outcome/spec.md and plan.md) designs a typed pending-call outcome and call correlation for long-running stdio tool calls that hit a tools/call timeout. In short:

  • A pending_call outcome (id, server, tool, reason, epoch) returned on MCP, REST and code_execution surfaces, additive to existing error shapes.
  • A registry of pending calls with late-completion tracking, cancel and reconcile operations, bounded memory with fair-share eviction.
  • A retry gate (pending_call_gate: off / warn / enforce) so a timed-out write is not blindly repeated while the original may still complete.
  • Principal and scope rules (agent tokens, anonymous profile, server edition), SSE/activity redaction, and audit behavior.

No code is changed in this PR.

Open questions, with recommended defaults

Please answer in review. Defaults apply if there is no objection.

# Question Recommended default
Q1 Emit a waiting_human reason? No, reserve the value. mcpproxy registers no upstream elicitation handler, so no deadline can fire while waiting on a human.
Q2 Default pending_call_gate mode warn this release; enforce available from phase 3; flip stdio to enforce one minor release later.
Q3 Does a successful explicit read-only call reconcile implicitly? Yes, only for entries whose late completion is not observable and show no recent progress, within the caller's gate scope. Live detached waits need explicit _meta["io.mcpproxy/reconciles"].
Q4 Forward caller cancellation upstream? Not in v1; revisit once mcp-go exposes an attributable cancel cause.
Q5 Should a stdio connection_reset open the gate? Yes, with the ambiguity documented. Alternative: keep gating until TTL.
Q6 Audit event for cancel_pending_call? No in v1 (Spec 107 excludes built-in invocations); activity and log only.

(Each is marked [NEEDS CLARIFICATION] context in the spec's "Clarifications Needed" section.)

Relationship to other work

Review history

Three cross-model review rounds were run (reviewer "sol" and a critic), all returning changes-needed, with revisions applied after rounds 1 and 2 (revision notes are at the end of the spec):

[{"round":1,"sol":"changes-needed","critic":"changes-needed"},{"round":2,"sol":"changes-needed","critic":"changes-needed"},{"round":3,"sol":"changes-needed","critic":"changes-needed"}]

This design needs maintainer review; it is intentionally not set to auto-merge.

Refs #1321

Related #1321

Spec and plan for items 1-3 of #1321 (item 4, timeout logging, landed
in #1513): call_id correlation, typed pending_call outcome layered on the
113-c taxonomy, (server, epoch, principal) pending-call registry with TTL,
write/destructive retry gate with self-healing refusal, cancel via
notifications/cancelled, MCP/REST/CLI parity. Three open questions with
recommended defaults.
Related #1321

Addresses all 29 review findings, each checked against origin/main
44244b8 and mcp-go v1.0.0 (module cache):

- Authoritative gate moved to managed.Client.callTool after the
  post-admission generation re-check (CheckAndBegin, serialized with
  MarkUnresolved). Covers activity replay and queued writes; advisory
  pre-admission check kept for slot economy.
- Gate tier is contracts.AnnotationTier with unannotated -> write and
  not-found -> destructive (tierForAnnotations maps unannotated to read).
- Always-detached stdio wait with a dispatch-time deadline, tie-to-result
  rule, publication handshake, tombstones and an epoch floor.
- No forwarding of caller cancellation in v1: mcp-go cancels the handler
  ctx with a plain WithCancel, so an explicit cancel cannot be told apart
  from a client timeout, script deadline, disconnect or shutdown.
- Typed JSON-RPC id; cancel pinned to the transport instance captured at
  dispatch.
- Stable token principal (Name + CreatedAt), current-scope and exact
  permission checks, per-principal SSE and activity filtering.
- HTTP/SSE entries survive epoch bumps; connection_reset, quarantine and
  restart documented as accepted ambiguity.
- Per-(server, principal) caps; other principals never evict a gating
  entry.
- Per-surface envelopes preserving the legacy error string; Spec 107
  audit arms and count invariants; tool-surface delta; tenant allowlist
  decision; phase reorder so enforce needs late completion and cancel.
- Spec 113 citations pinned to d5de932; assumptions labelled.

Rejected as false positives: none. Narrowed: the configurable caller
cancel forwarding mode is deferred to Q4, and SSE ownership is carried
by an internal attribute stripped before send.
Related #1321

Address 20 review findings on the pending-call spec and plan. Each was
checked against origin/main 44244b8 and mcp-go v1.0.0.

- Authorization uses auth.ScopedView; confined anonymous callers become
  an anonymous_profile principal, never a raw IsAdmin() admin.
- Global cap: records are only created at CheckAndBegin; evict, fold
  gating entries into per-scope overflow blockers, else refuse before
  dispatch (pending_registry_full).
- Cancel is authorized against the admission tier; reconcile and cancel
  availability are computed per caller and server; FR-034 claim narrowed.
- Both refusal points are audited as tool_call rejected (authz allow is
  already written at Started; auditAuthz is first-write-wins).
- REST capture box keeps the legacy string; code_execution extends its
  {ok:false,error} envelope; replay returns typed errors and gets its own
  record fields.
- Tenant allowlist gets a named must-refuse for /servers/{id}/pending-calls.
- Principal id includes the token owner (names are unique per owner).
- Activity resolution made reliable with a registry-driven resync and
  an activity-only untracked state.
- HTTP/SSE cancel is local-only in v1.
- Detached waits: dynamic fair share with preemption, three-principal test.
- Implicit reconcile only for non-observable entries; explicit _meta
  reconciles otherwise; local principal gated per MCP session.
- Registry methods return transitions emitted outside epochMu.
- mcp-go multi-round calls: ordered id list, input_required late outcome.
- Spec 113 FR-049 fallback acknowledged; mcp-go citations labelled as
  dependency assumptions with pin tests.

Rejected in part: the claim that a deadline inside fulfillInputRequests
is a human-wait signal. mcpproxy registers no elicitation, sampling or
roots handler on upstream clients, so that path fails immediately; Q1
stays as is.
@Dumbris

Dumbris commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Open findings from review round 3 (not yet addressed)

The spec went through 3 review rounds (opencode gpt-6.1-sol + an Opus critic). Findings fell from 35 to 21, but round 3 still reported the following. Address them before tasks.md and implementation:

  • Sol 6.1 P1 (FR-010 dispatch safety): Always-detached dispatch (WithoutCancel) can send work that today never reaches the wire when ctx is already cancelled. Need a pre-send cancellation check, a reserved-vs-sent distinction, and tests for expired script batch workers and cancel between CheckAndBegin and SendRequest.
  • Sol 6.1 P2 (FR-007a bounds): Total memory is still unbounded: overflow blockers are exempt from record caps, local session scopes have no cap, and mapping every folded call_id to its blocker needs unbounded aliases. Define hard caps and conservative overflow behavior for blockers and aliases.
  • Sol 6.1 P2 (FR-007a transition-time bounds): CheckAndBegin alone cannot enforce the unresolved-entry caps: many admitted dispatched records can all convert to unresolved via MarkUnresolved past the 16-gating/256-read limits. Specify folding or eviction at the transition, including releasing folded detached waits.
  • Sol 6.1 P2 (FR-018/021 overflow blockers): The blocker aggregate loses information: oldest_unresolved_at lets a read predating newer folded calls clear them, and max_gate_tier cannot represent mixed non-hierarchical read/write/destructive permissions for exact-permission cancel. Store a latest-unresolved watermark and a permission set; test mixed aggregates.
  • Sol 6.1 P2 (FR-003a script deadline correlation): The promised final pending_calls list has no publication barrier: Execute returns on script timeout without waiting for the script goroutine, so sub-calls may not have called MarkUnresolved before the result is serialized. Define synchronized collection or bounded drain; test paused timeout publication.
  • Sol 6.1 P2 (FR-018 explicit reconciliation surfaces): params._meta is a usable MCP carrier, but REST discards it and JS host calls expose only server/tool/args, so the plan promises reconciliation on surfaces with no defined acknowledgment carrier. Specify carriers and end-to-end tests without injecting proxy metadata into upstream args.
  • Sol 6.1 P2 (FR-024a activity reliability): Resync acknowledges tombstones even when the activity row/index may not yet exist, so the bounded held map can expire before insertion; fallback waits TTL+1m, contradicting the one-resync-interval recovery promise. Acknowledge only after durable application and define missing-row/eviction recovery timing.
  • Sol 6.1 P2 (FR-019 cancellation ordering): authorize -> send -> mark leaves races with completion/reconcile/TTL unspecified: a call can turn terminal before the notification is sent yet the operation still reports cancelled, contradicting terminal-state idempotence. Define an atomic cancel claim, response precedence, and cancel-vs-late-response tests.
  • Opus critic high (FR-009 / FR-010 / Context item 5 (interaction with the Chrome DevTools MCP --autoConnect: tool calls hang indefinitely through MCPProxy stdio transport #1317 reconnect guard)): Post-deadline detached waits are invisible to the Chrome DevTools MCP --autoConnect: tool calls hang indefinitely through MCPProxy stdio transport #1317 in-flight reconnect guard. In the Chrome DevTools MCP --autoConnect: tool calls hang indefinitely through MCPProxy stdio transport #1317 scenario, health-check-driven reconnects can therefore kill the stdio child during the unresolved window. That turns the entry into connection_reset and opens the gate, which defeats the feature for its motivating case.
  • Opus critic high (US2 AS6 vs FR-020a (gate scope for REST/replay)): US2 AS6 says replay and REST of the timed-out call are refused. But FR-020a puts local REST and replay calls in the gate scope local/nosession, while the original MCP call sits in local/session:, so the gate never matches. With no agent tokens (the personal-edition default), the most obvious blind retry, Web UI 'Replay' of the timed-out navigate_page, goes through ungated.
  • Opus critic medium (FR-007a global cap step 3 vs US4 AS4 / SC-006): Global-cap step 3 folds the gating entries of 'the gate scope that holds the most gating entries'. That lets principal T2's insertions fold T1's entries, which contradicts US4 AS4 ('never folded by T2's insertions'), and T1 then loses late completion and cancel notifications. Separately, nothing caps dispatched records per principal, so a single token holding many in-flight calls can drive every other principal into pending_registry_full refusals.
  • Opus critic medium (Plan Phase 1 / Rollout / FR-017): The pre-dispatch capacity refusal (pending_registry_full, REST 429) ships in Phase 1 and applies in every mode, including off. That contradicts Phase 1's 'No behavior change for any call' and the rollback claim that pending_call_gate: off disables refusals. If the registry has a bug, operators have no kill switch.
  • Opus critic medium (FR-013 / FR-009 (epoch stamped before the transport is chosen)): The record's epoch is the callEpoch read before invoker.CallTool, but the transport is picked later: core.Client reads c.client under RLock, and coreClient is never replaced. A Disconnect+Connect between CheckAndBegin and SendRequest sends the call on the new generation's transport while the record still carries the old epoch. ResolveEpoch then tombstones it as connection_reset, and the gate is open while the call runs on the live child.
  • Opus critic medium (FR-003 / FR-008 / FR-010 ('dispatched' ≠ sent)): A record becomes dispatched at CheckAndBegin, before the request reaches the wire. If the caller's ctx ends before the send, the spec creates an unresolved gating entry with upstream_may_still_be_running:true for a call the upstream never received. On SSE that means a 10-minute block. On stdio, late_completion_observable is set by transport type, so the entry is 'observable' with nothing outstanding, which disables implicit reconcile.
  • Opus critic medium (FR-003 / FR-004 (transport-internal timeouts)): Transport-level timeouts that fire while the caller's ctx is still live are unhandled. The call becomes 'dispatched → completed' (dropped), with no pending block and no gate, even though the upstream may still be running. This is exactly the case of a user who raises call_tool_timeout for human-in-the-loop tools.
  • Opus critic medium (FR-027 / SC-004 (count leak through servers.changed)): Server payloads gain pending_calls, described as 'visible to the caller', but the servers.changed SSE embed is built once with no caller and fanned out. The per-subscriber renderer narrows only by server, so a scoped token subscribed to /events sees the global pending count on a shared server. That is a cross-principal side channel, and SC-004 claims zero leakage.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 778887f
Status: ✅  Deploy successful!
Preview URL: https://31e8857e.mcpproxy-docs.pages.dev
Branch Preview URL: https://114-pending-call-outcome.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 114-pending-call-outcome

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (27 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (24 MB)
  • smart-mcp-proxymcpproxy-goWKOBX8.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 37485745404 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

This branch has not been deployed

No deployments
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.

2 participants