feat: notifications/tasks — task status pushes via subscriptions/listen taskIds (SEP-2663) - #85
Merged
Merged
Conversation
…en taskIds (SEP-2663) The tasks extension's status notifications: subscriptions/listen accepts a taskIds filter entry (array of task ids, capped like resource subscriptions). Requesting taskIds without declaring the io.modelcontextprotocol/tasks extension is the extension's -32021 (spec MUST; the ext-tasks doc's -32003 is the stale pre-renumber code), rejected before any stream is established. The acknowledgment echoes only the ids the requestor could tasks/get right now — same principal, extension era, per-request scope re-authorization — so unknown and foreign ids are silently omitted and subscription probing leaks nothing polling would not. Every extension-era status transition — parking on input (with the inputRequests snapshot), resuming after tasks/update, completing, failing, cancelling, ttl-expiring — broadcasts a complete DetailedTask (identical to tasks/get at that moment, subscriptionId-tagged) to the streams watching that id. Delivery rides a new store-level on_status_change hook installed by mcp_server, fired under the store lock at each transition site (the ordering store -> registry -> transport channels is acyclic and all deliveries enqueue without blocking); a throwing hook never breaks the transition, and legacy-era records never fire it. expire_parked_input! now takes the store (its hook needs it); both callers updated. Conformance: modern-dated 40/40 and all baselines unchanged; the suite's tasks-status-notifications scenario remains its documented unconditional SKIP — the 31 new suite tests carry the coverage (gating, filter validation, ack echo/dropping, park/resume/complete/cancel pushes with payload assertions, unsubscribed-task silence, legacy-era silence). Suite 1826/1826; tasks-ext file 221/221 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… per Codex round 1 BLOCK 1: the -32021 extension gate ran AFTER filter validation — a non-declaring client sending 257 well-formed ids got -32602, and an empty taskIds array established a stream ungated. The gate now keys on the PRESENCE of the taskIds entry and runs before parse_subscription_filter. BLOCK 2: the status-change hook did synchronous transport I/O (stdio write + flush) while holding the store lock and the registry lock — a client that stopped draining its pipe wedged every tasks/* request, timer expiry, and subscription operation indefinitely. The hook now only snapshots (wire + transition-time principal/required_scopes) into a bounded FIFO (TASK_NOTIFICATION_QUEUE_CAP=1024, single producer under the store lock, so the availability check cannot race); a single dispatcher task broadcasts outside the store lock, preserving transition order; overload load-sheds (pushes are best-effort, polling stays authoritative); stop! closes the queue, ending the dispatcher, and post-stop transitions survive the closed channel. WARN 1: a transition landing between listen-time authorization and registration was never pushed (the ack named the id; no notification followed). After registration each subscribed task's CURRENT state is fired through the normal status-change path — complete-state semantics make the extra push benign. WARN 2: authorization was checked only at listen time against mutable required_scopes — a stream could keep receiving a task it could no longer tasks/get. SubscriptionRecord now carries the requestor's principal + scopes (back-compat 4-arg constructor kept), and every delivery re-checks them against the task's transition-time requirements via a new authorize predicate on broadcast_subscription_notification (evaluated under the registry lock only — no store-lock acquisition, the ordering never reverses). WARN 3: honored-id resolution ran up to 256 get_task calls (each sweeping the whole store) before any capacity check. Resolution now takes ONE store lock and ONE sweep, batch-resolving all ids, behind a cheap registry capacity precheck (the race-safe check at registration remains authoritative). Suite 1837/1837; tasks-ext file 232/232 on Julia 1.11; conformance modern 40/40, stateless 30/30, baselines intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…seq guard; dispatcher lifecycle per Codex round 2 BLOCK: the capacity precheck counted registry records without sweeping dead routes — 64 vanished HTTP streams on a quiet server denied the entire listen surface with -32603 forever (nothing else would ever prune them). The precheck now sweeps exactly like registration does. WARN: the delivery predicate required issubset(required_scopes, stream scopes) unconditionally, but unauthenticated transports have no scopes, not insufficient ones — a scoped task was acknowledged yet received zero pushes on stdio. The scope re-check now applies only to authenticated principals, mirroring ext_task_authorized exactly. WARN: a queued-but-undrained backlog could replay older states to a freshly registered stream (subscribe-B-after-cancel received working, cancelled, cancelled). Every enqueued event now carries a monotone sequence (TaskStore.notification_seq, incremented only under the store lock — single producer), each SubscriptionRecord stores its registration threshold, and the dispatcher delivers only later-stamped events: a new stream's first task message is its own initial snapshot. WARN: the dispatcher retained the server past its lifetime on the normal EOF/error shutdown path (only explicit stop! closed the queue), and a stop!-then-start! server had notifications permanently dead. start! now calls the new ensure_task_notifications! (fresh queue + dispatcher when absent or closed) and its finally closes the queue on EVERY loop exit. NIT: _fire_status_change docstring updated to the enqueue-only design. Suite 1844/1844; tasks-ext file 239/239 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Codex round 3 WARN 1: the sequence threshold was sampled BEFORE the acknowledgment delivery, whose stdio write can stall arbitrarily long — transitions stamped during the stall passed the threshold and replayed pre-registration states to the new stream (Codex's deterministic probe: working, cancelled, cancelled). The threshold is now read inside the registry critical section AT THE PUSH, after the ack: the residual window between read and registration contains no I/O, so at most an event stamped in that instant precedes the initial snapshot — practically current and still ordered. (An exact boundary would require the store lock inside the registry section — the forbidden reverse ordering.) WARN 2: an immediate stop! -> start! let the OLD run's finally close the fresh queue the new run had just installed (the field is shared across generations). Each run now captures its own queue after ensure_task_notifications! and the finally closes only that generation; a fresh queue from an overlapping restart survives. Regression-tested at the generation level. Suite 1845/1845; tasks-ext file 240/240 on Julia 1.11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…odex round 4
WARN 1: notification_seq was a plain Ref written under the store lock but read
for registration thresholds under the unrelated registry lock — an
unsynchronized cross-lock access is a data race on a threaded runtime, and a
stale read after a stalled ack could re-admit queued pre-registration states.
Now Threads.Atomic{Int}: atomic_add! at the (still single-producer) stamp site,
atomic load at the threshold read; no lock-order change needed.
WARN 2: a restart installed on_status_change without the store lock while
transition sites read it under that lock — an old detached task completing
concurrently with stop! -> start! raced the publication of the new queue
closure. The hook assignment now happens under server.tasks.lock.
Suite 1845/1845; tasks-ext file 240/240 on Julia 1.11.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the tasks extension's status notifications (
notifications/tasks, SEP-2663) — the last feature gap in theio.modelcontextprotocol/taskssurface. Clients subscribe by task id throughsubscriptions/listen, and every extension-era status transition pushes the task's complete state.Design
subscriptions/listengains ataskIdsfilter entry (array of task ids, capped at 256 like resource subscriptions; malformed →-32602). RequestingtaskIdswithout declaring theio.modelcontextprotocol/tasksextension in the request's_metaclientCapabilities is the extension's-32021(spec MUST — the ext-tasks doc's-32003is the stale pre-renumber code), rejected before any stream is established.tasks/getwould — same principal, extension era, per-request scope re-authorization — and unknown, foreign-principal, or insufficiently-scoped ids are silently omitted, so subscription probing leaks nothing a poll would not.inputRequestssnapshot), resuming aftertasks/update, completing (result inlined), failing (error inlined), cancelling, and ttl-expiry — identical to whattasks/getwould return at that moment, tagged with the stream'ssubscriptionIdper the listen-stream contract.on_status_changehook (aRefonTaskStore, installed bymcp_serverviainstall_task_notifications!) fired at all five transition sites while the store lock is held — the wire snapshot requires it, and the ordering store → subscription registry → transport channels is acyclic with every delivery path enqueueing without blocking. A throwing hook never breaks the transition it observes; legacy-era (SEP-1686) records never fire it.expire_parked_input!now takes the store (its hook needs it); both callers updated.Verification
-32021gating, filter validation, ack echo with unknown-id dropping, park → resume → completed push sequence with payload andsubscriptionIdassertions, cancellation push, unsubscribed-task silence, legacy-era silence). Tasks-extension file 221/221 on Julia 1.11.server-stateless30/30, tasks-* baselines intact. The official suite'stasks-status-notificationsscenario is an unconditional SKIP upstream (pending their harness rewrite), so the suite tests above carry the coverage.Remaining for 0.7 (tracked in CLAUDE.md):
x-mcp-headerparameter mirroring, Documenter docs for the tasks extension.🤖 Generated with Claude Code