Skip to content

feat: notifications/tasks — task status pushes via subscriptions/listen taskIds (SEP-2663) - #85

Merged
kalidke merged 5 commits into
mainfrom
feature/tasks-notifications
Aug 5, 2026
Merged

feat: notifications/tasks — task status pushes via subscriptions/listen taskIds (SEP-2663)#85
kalidke merged 5 commits into
mainfrom
feature/tasks-notifications

Conversation

@kalidke

@kalidke kalidke commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Implements the tasks extension's status notifications (notifications/tasks, SEP-2663) — the last feature gap in the io.modelcontextprotocol/tasks surface. Clients subscribe by task id through subscriptions/listen, and every extension-era status transition pushes the task's complete state.

Design

  • subscriptions/listen gains a taskIds filter entry (array of task ids, capped at 256 like resource subscriptions; malformed → -32602). Requesting taskIds without declaring the io.modelcontextprotocol/tasks extension in the request's _meta clientCapabilities 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 server agreed to: each requested id is resolved exactly as tasks/get would — 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.
  • Every transition pushes a complete DetailedTask — parking on input (with the inputRequests snapshot), resuming after tasks/update, completing (result inlined), failing (error inlined), cancelling, and ttl-expiry — identical to what tasks/get would return at that moment, tagged with the stream's subscriptionId per the listen-stream contract.
  • Delivery mechanism: a store-level on_status_change hook (a Ref on TaskStore, installed by mcp_server via install_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

  • Suite 1826/1826 (31 new tests: -32021 gating, filter validation, ack echo with unknown-id dropping, park → resume → completed push sequence with payload and subscriptionId assertions, cancellation push, unsubscribed-task silence, legacy-era silence). Tasks-extension file 221/221 on Julia 1.11.
  • Conformance unchanged: modern-dated 40/40, server-stateless 30/30, tasks-* baselines intact. The official suite's tasks-status-notifications scenario 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-header parameter mirroring, Documenter docs for the tasks extension.

🤖 Generated with Claude Code

kalidke and others added 5 commits August 4, 2026 14:37
…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>
@kalidke
kalidke merged commit 039f0ae into main Aug 5, 2026
8 checks passed
@kalidke
kalidke deleted the feature/tasks-notifications branch August 5, 2026 01:16
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