Skip to content

feat: set_presence REST endpoint, MCP tool, and shared PresenceStatus enum#33

Merged
tlongwell-block merged 7 commits into
mainfrom
feat/set-presence
Mar 11, 2026
Merged

feat: set_presence REST endpoint, MCP tool, and shared PresenceStatus enum#33
tlongwell-block merged 7 commits into
mainfrom
feat/set-presence

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Mar 11, 2026

Summary

Add PUT /api/presence so GUI clients and agents can set their presence status without requiring a WebSocket connection. Introduces a shared PresenceStatus enum in sprout-core as the single source of truth, used by both the relay REST API and the MCP agent surface.

Implements the set_presence MCP tool that was explicitly deferred in #28:

set_presence MCP tool (requires new REST endpoint or WS event dispatch)

What Changed

Shared Types (sprout-core)

  • PresenceStatus enum (Online, Away, Offline) defined once in sprout-core::presence
  • schemars::JsonSchema derived conditionally via mcp-schema feature flag — zero overhead for non-MCP consumers
  • Includes Display, as_str(), PartialEq/Eq, and serde rename_all = "lowercase"
  • 4 unit tests: serde roundtrip, unknown variant rejection, as_str, Display

REST API (sprout-relay)

  • PUT /api/presence — accepts {"status": "online" | "away" | "offline"}
    • Returns {"status": "...", "ttl_seconds": N} (90 for online/away, 0 for offline)
    • "offline" calls clear_presence() (Redis DEL); others call set_presence() (Redis SET EX 90)
    • Auth via extract_auth_pubkey — users can only set their own presence
  • ApiJson<T> custom extractor — wraps axum::Json<T> to return standard {"error": "..."} envelope on deserialization failure, preserving Axum's original status codes (400/415/422)

MCP Tools (sprout-mcp)

  • set_presence tool (fix: just reset race condition #41) with typed PresenceStatus enum in the JSON schema — gives LLM callers a tight, validated schema instead of a free-form string
  • Error propagation improvedRelayClient GET/POST/PUT/DELETE/get_api now include status code, URL, and response body in error messages

TESTING.md

  • Tool count updated 40 → 41 throughout
  • Test counts updated: 40 REST, 14 relay, 14 MCP = 68 total
  • Presence tools table updated (1 → 2 tools)
  • Exercises A-5 and B-7 updated to use set_presence
  • Expected results table updated with presence verification row

Testing (9 new e2e tests + 4 unit tests)

  • 5 REST tests: set online (+ TTL), away→offline transitions (+ TTL + status), invalid status (422 + error envelope), auth required (401 + body), missing field (422 + envelope)
  • 4 MCP tests: set online (+ TTL), offline (+ TTL), away (+ TTL), invalid status (JSON-RPC error)
  • All 68 integration tests pass (40 REST + 14 relay + 14 MCP)
  • cargo fmt, cargo clippy, cargo check all clean

Files Changed (13 files, +560/-30)

File Change
crates/sprout-core/src/presence.rs NewPresenceStatus enum, single source of truth
crates/sprout-core/Cargo.toml schemars optional dep, mcp-schema feature
crates/sprout-core/src/lib.rs pub mod presence, pub use PresenceStatus
crates/sprout-mcp/Cargo.toml Enable mcp-schema feature on sprout-core
crates/sprout-mcp/src/server.rs set_presence tool using shared enum
crates/sprout-mcp/src/relay_client.rs Error body propagation (all 5 HTTP methods)
crates/sprout-relay/src/api/presence.rs set_presence_handler with TTL response
crates/sprout-relay/src/api/mod.rs ApiJson<T> custom extractor
crates/sprout-relay/src/router.rs GET(...).PUT(...) on /api/presence
crates/sprout-test-client/tests/e2e_mcp.rs 4 new tests, tool count 41, JSON-parsed assertions
crates/sprout-test-client/tests/e2e_rest_api.rs 5 new tests with TTL + error envelope verification
TESTING.md Updated tool count, test counts, exercises, expected results
Cargo.lock Updated lockfile

Design Notes

  • WebSocket path (kind:20001) is unchanged — it still accepts arbitrary status strings for forward-compatibility. The REST/MCP surface intentionally restricts to the curated PresenceStatus enum. This divergence is documented in the handler doc comment.
  • PUT is the correct verb — presence is a single-field resource being fully replaced.
  • TTL in response — returning ttl_seconds lets clients know when to send the next heartbeat without hardcoding the value.
  • ApiJson<T> preserves Axum's original status codes while wrapping rejections in the standard error envelope, so 415 (wrong Content-Type), 400 (malformed JSON), and 422 (unknown enum variant) all get the right status code with a consistent body format.
  • Feature flag designschemars is optional in sprout-core behind mcp-schema, keeping the zero-I/O-dependency principle intact for non-MCP consumers.

Test Results

test result: ok. 40 passed; 0 failed; 0 ignored   ← REST API
test result: ok. 14 passed; 0 failed; 0 ignored   ← relay
test result: ok. 14 passed; 0 failed; 0 ignored   ← MCP

All 68 integration tests pass. 4 sprout-core unit tests pass.

…P-05

Add REST endpoints for reading any user's profile and batch-resolving
display names, MCP tools for agent profile access, kind:0 side-effect
sync from Nostr metadata events, and a working NIP-05 identity endpoint.

REST API:
- GET /api/users/{pubkey}/profile — read any user's profile (auth required)
- POST /api/users/batch — batch resolve display names (max 200, dedup,
  case-normalize; invalid-length inputs returned in `missing`)

MCP tools:
- get_user_profile — read own or any user's profile by pubkey
- get_users_batch — batch resolve display names for message attribution

Kind:0 side effect:
- NIP-01 profile metadata events now sync to the users table
- Absolute-state semantics: absent fields are cleared (set to NULL)
- Fields: display_name (with name fallback), avatar_url (picture/image),
  about, nip05_handle
- Empty strings converted to NULL via empty_to_none() to respect the
  UNIQUE constraint on nip05_handle

NIP-05 (/.well-known/nostr.json):
- Replaces empty stub with working identity verification
- Exact domain match against relay_url (no LIKE wildcards)
- Access-Control-Allow-Origin: * header (NIP-05 spec requirement)
- No authentication required (public discovery endpoint)

DB changes:
- update_user_profile gains nip05_handle parameter
- New get_user_by_nip05 function (exact match, not LIKE)
- No schema migrations required

Tests:
- 12 new REST API e2e tests (profile CRUD, batch, NIP-05, auth, edges)
- 3 new MCP e2e tests (self profile, other profile, batch)
- Tool count assertion updated from 36 to 38
- All 55 e2e tests pass (32 REST + 10 MCP + 13 relay)
- Update MCP tool count from 36 to 38 across all references
- Add get_user_profile and get_users_batch to Profile tools table
- Update integration test counts: 32 REST, 10 MCP, 55 total
  (plus 7 workflow tests noted separately)
- Add B-8 exercise: Bob tests profile resolution via MCP tools
- Add C-8 exercise: Charlie verifies NIP-05 identity endpoint
- Add C-9 exercise: Charlie tests profile lookup edge cases
- Update A-5 to set Alice's NIP-05 handle (alice@localhost)
- Update Expected Results table with profile and NIP-05 rows
- Update 'as of' date to 2026-03-11
…, update TESTING.md

- Add 'search' MCP tool wrapping GET /api/search (Typesense full-text)
- Add 'get_presence' MCP tool wrapping GET /api/presence (bulk lookup)
- Fix set_profile to support nip05_handle field (was silently dropped)
- Thread nip05_handle through UpdateProfileBody → update_user_profile
- Update TESTING.md section 6 tool table: correct all tool names,
  add missing tools (40 total), remove nonexistent tools
- Fix exercises A-5, B-6 for accuracy; add bootstrap channel timing note
- Fix expected results: Charlie in profiles map (not missing list)
- Update e2e test assertions: 38 → 40 tools
…ests

Crossfire R1 + R2 fixes:
- Validate nip05_handle format (user@domain) and restrict domain to
  relay's own domain on both REST and kind:0 ingestion paths
- Return 409 Conflict on duplicate nip05_handle (was generic 500)
- Allow empty string to clear nip05_handle (empty → NULL via DB layer)
- Kind:0 side effect now validates NIP-05 format and domain before
  syncing to users table (invalid handles silently cleared)
- Add percent_encode to get_presence MCP tool for consistency
- Add 3 NIP-05 integration tests: round-trip set+lookup, clear handle,
  duplicate handle conflict
- Update doc comments to include nip05_handle
- Make extract_domain pub(crate) for reuse across modules
…lience

Crossfire R3 fixes:
- Extract shared canonicalize_nip05() in nip05.rs — lowercases local+domain,
  validates format and relay domain match, used by both REST and kind:0 paths
- Batch endpoint: 64-char non-hex inputs now correctly reported in missing
  list instead of silently dropped
- Kind:0 duplicate NIP-05: retry profile sync without contested handle so
  display_name/about/avatar_url are still written (UNIQUE violation no longer
  drops all profile fields)
- Add kind:0 NIP-05 regression test: valid handle syncs, off-domain cleared
- Update batch test to cover non-hex 64-char inputs
… enum

Add PUT /api/presence so GUI clients and agents can set presence without
a WebSocket connection. The existing GET endpoint and kind:20001 WebSocket
path are unchanged.

REST (sprout-relay):
- PUT /api/presence accepts {"status": "online"|"away"|"offline"}
- Returns {"status": "...", "ttl_seconds": N} (90 for online/away, 0 for offline)
- "offline" calls clear_presence (Redis DEL); others call set_presence (Redis SET EX 90)
- Auth via extract_auth_pubkey — users can only set their own presence
- Custom ApiJson<T> extractor returns standard {"error": "..."} envelope on
  deserialization failure, preserving Axum's original status codes (400/415/422)

MCP (sprout-mcp):
- New set_presence tool (#41) with typed PresenceStatus enum in JSON schema
- relay_client error propagation improved: all HTTP methods now include
  status code, URL, and response body in error messages

Shared types (sprout-core):
- PresenceStatus enum (Online/Away/Offline) defined once in sprout-core
- schemars::JsonSchema derived conditionally via mcp-schema feature flag
- Imported by both sprout-relay and sprout-mcp — single source of truth
- Includes Display, as_str(), serde roundtrip, and 4 unit tests

Testing:
- 5 new REST e2e tests: set online, away+offline transitions, invalid
  status (422 + envelope), auth required (401 + body), missing field (422)
- 4 new MCP e2e tests: set online, offline, away (all with TTL assertions),
  invalid status
- Tool count updated 40 -> 41 with JSON-parsed assertions throughout
- All tests verify ttl_seconds in PUT responses
- cargo fmt, cargo clippy, cargo check all clean
* origin/main:
  Plumb desktop profile identity UI (#32)
  Add unread indicators to desktop channels (#31)
  feat: public profiles, NIP-05 identity, search & presence tools (#28)
  Add desktop settings page (#30)
  Remove OS titlebar and add custom window chrome (#29)

# Conflicts:
#	TESTING.md
#	crates/sprout-mcp/src/server.rs
#	crates/sprout-test-client/tests/e2e_mcp.rs
@tlongwell-block tlongwell-block merged commit 397564b into main Mar 11, 2026
8 checks passed
@tlongwell-block tlongwell-block deleted the feat/set-presence branch March 11, 2026 20:05
wpfleger96 added a commit that referenced this pull request May 22, 2026
The zizmor scanner runs as an org-level GHAS integration, not from a
repo checkout — it never reads .github/zizmor.yml. Dismissed the
cache-poisoning alerts (#10-13, #32, #33) as false positives via the
code-scanning API instead, with references to the upstream bug
(zizmorcore/zizmor#2051).
wpfleger96 added a commit that referenced this pull request May 22, 2026
The zizmor scanner runs as an org-level GHAS integration, not from a
repo checkout — it never reads .github/zizmor.yml. Dismissed the
cache-poisoning alerts (#10-13, #32, #33) as false positives via the
code-scanning API instead, with references to the upstream bug
(zizmorcore/zizmor#2051).
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