Skip to content

feat(mcp): event-driven push — broadcast + subscribe_annotations (#185 PR3/5)#190

Merged
gerchowl merged 3 commits intodevfrom
land/185-pr3-mcp-push
Apr 28, 2026
Merged

feat(mcp): event-driven push — broadcast + subscribe_annotations (#185 PR3/5)#190
gerchowl merged 3 commits intodevfrom
land/185-pr3-mcp-push

Conversation

@gerchowl
Copy link
Copy Markdown
Contributor

Summary

Closes the MCP-side push P0 of #185 — the agent loop is now event-driven, not poll-driven.

  • New events module: AnnotationEvent { paper_id, annotation_id, kind, reader } and a 256-slot tokio::sync::broadcast channel on ScitadelServer. Bounded so a slow subscriber sees `Lagged(n)` rather than growing memory unbounded.
  • Every annotation write tool emits one event after the underlying tool returns Ok: `create_annotation` → `Created`, `reply_annotation` → `Replied`, `update_annotation` → `Updated`, `delete_annotation` → `Deleted`, `mark_seen` → `MarkedSeen` (per id), `mark_thread_seen` → `MarkedThreadSeen`. `paper_id` is resolved via `tools::lookup_annotation_paper_id` BEFORE the operation so the delete path doesn't lose scope information after the tombstone.
  • New `subscribe_annotations(paper_id?, reader)` tool returns `scitadel://annotations/all` or `scitadel://annotations/<paper_id>` and spawns one tokio task per call that translates each matching event into an MCP-spec `notifications/resources/updated` for the calling peer.
  • Lifecycle: task ends on `RecvError::Closed` (server shutdown) or peer notify failure (client disconnect). No explicit unsubscribe RPC; closing the connection is the unsubscribe signal.

Commits

  1. `572094a` events module + broadcast + emit on every write tool
  2. `62c9968` subscribe_annotations tool + URI helper + scope filter
  3. `ba9bf4d` review-driven: e2e emit test + reject empty paper_id

Test plan

  • 65/65 `cargo test -p scitadel-mcp --lib` pass (up from 56 pre-PR)
  • Channel: single sub, two subs each get a copy, no-sub fail-silent, lag-rather-than-block, kind.as_str stability
  • URI shape: None / Some(p) / Some("") (no panic; no panic in helper, but tool rejects with Err)
  • Scope filter: None matches all, Some matches equal, empty-string never matches a real event
  • Server-Sender → server-Receiver wiring round-trips
  • End-to-end: `create_annotation` through the server surface emits a Created event with correct paper_id / annotation_id
  • `cargo clippy -p scitadel-mcp --tests -- -D warnings` clean
  • `cargo fmt --check` clean

Out of scope (follow-ups)

  • TUI integration with the new push channel (PR4 / PR5 territory)
  • `create_paper_note` (PR4)
  • Selection-fidelity fix in `publish_tui_state` reader mode (PR5)

[tape-exempt: MCP-only push channel; no TUI/CLI surface touched]

Refs #185.

Lays the foundation for MCP-side push (PR3 part 1 of 2):

- New `events` module with `AnnotationEvent { paper_id, annotation_id,
  kind, reader: Option<String> }` and a `tokio::sync::broadcast`
  channel sized to 256 events. Slow subscribers see `Lagged(n)` on
  recv and re-fetch via `list_annotations`; we'd rather drop than
  grow unbounded.
- `ScitadelServer` carries the `Sender`. The initial `Receiver` from
  `broadcast::channel` is discarded so emit-with-no-subscribers is a
  no-op (Sender::send returns SendError, swallowed in `events::emit`).
- `subscribe_events()` hands out a fresh receiver for the upcoming
  `subscribe_annotations` tool.
- Every annotation write tool now emits one event:
    create_annotation     → Created      (paper_id from req)
    reply_annotation      → Replied      (paper_id from parent lookup)
    update_annotation     → Updated      (paper_id from id lookup)
    delete_annotation     → Deleted      (paper_id from id lookup BEFORE soft-delete)
    mark_seen             → MarkedSeen   (one event per id, with reader)
    mark_thread_seen      → MarkedThreadSeen (event on root_id, with reader)
- `tools::lookup_annotation_paper_id` helper resolves paper_id by
  annotation id so the server doesn't open the DB inline. Returns
  None on missing/deleted rows; the server logs and skips the emit.

Tool descriptions updated to advertise the
`notifications/resources/updated` emission so clients know the tools
are subscribe-aware.

Tests: 5 events module tests (single sub, two subs each get a copy,
no-sub fail-silent, lag-rather-than-block, kind.as_str stability) +
one server-level subscribe_events round-trip test. The actual
emit-on-write wiring is verified visually — each method is a small
glue layer over the already-tested broadcast channel.

Refs #185.

[tape-exempt: MCP-only event channel; no TUI/CLI surface touched]
The subscribe side of #185 PR3. An agent calls
`subscribe_annotations(paper_id?, reader)` and receives a resource
URI; the server spawns a per-call task that translates every
matching `AnnotationEvent` into an MCP-spec
`notifications/resources/updated` for the calling peer.

URI scheme:
- `scitadel://annotations/all` — all papers
- `scitadel://annotations/<paper_id>` — paper-scoped

Lifecycle:
- One spawned task per subscribe call
- Task ends on broadcast Sender close (server shutdown) or peer
  notify failure (client disconnect) — no explicit unsubscribe RPC
- Lagged subscribers see one resync update + a tracing warning;
  agent should re-fetch via list_annotations to recover

Pure helpers `subscription_uri` and `event_matches_scope` factor out
the URI shape and routing logic so the contract is unit-testable
without spawning the broadcast task or mocking a Peer.

3 new tests: URI shape (None / Some / empty), event-matches-scope
filter (None matches all, paper-scoped matches only that paper).

Refs #185.

[tape-exempt: MCP-only push channel; no TUI/CLI surface touched]
…R3 review)

Two soft suggestions from the review, both addressed:

- New end-to-end test drives `create_annotation` through the server
  surface and asserts a `Created` event arrives on `subscribe_events()`.
  Insurance against a future refactor that reroutes a write tool past
  `events::emit`. The other 5 emit sites are mechanically identical so
  one good test guards them all.
- `subscribe_annotations(paper_id: Some(""))` now returns an explicit
  Err. Without this the caller got a valid-looking
  `scitadel://annotations/` URI and a scope filter that would never
  match a real event — silent zero-delivery. Treat as caller error.

Refs #185.

[tape-exempt: MCP-only test + validation; no TUI/CLI surface touched]
@gerchowl gerchowl merged commit 348993c into dev Apr 28, 2026
19 checks passed
@gerchowl gerchowl deleted the land/185-pr3-mcp-push branch April 28, 2026 20:13
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