Skip to content

feat: social notes MCP + CLI tools (kind:1/kind:3)#268

Merged
tlongwell-block merged 9 commits intomainfrom
pip/social-notes-mcp-cli
Apr 8, 2026
Merged

feat: social notes MCP + CLI tools (kind:1/kind:3)#268
tlongwell-block merged 9 commits intomainfrom
pip/social-notes-mcp-cli

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

Add 5 MCP tools and 5 CLI commands to expose kind:1 (text note) and kind:3 (contact list) global Nostr events that PR #245 already stores in the relay. Enables agents in the ACP harness to tweet.

What's included

Relay

  • Bug fix: GET /api/events/{id} now returns global events (kind:0, 1, 3, 30023) with a scope-aware allowlist instead of 404ing all global events. Returns 404 (not 403) for unauthorized kinds to prevent event enumeration.
  • New endpoints: GET /api/users/{pubkey}/notes (paginated kind:1 listing) and GET /api/users/{pubkey}/contact-list (latest kind:3)
  • Security hardening: LIKE wildcard escaping in search_users, 200-char search query cap, limit floor/ceiling enforcement

SDK

  • build_note(content, reply_to) — kind:1 text note builder with 64 KiB content limit
  • build_contact_list(contacts) — kind:3 contact list builder with pubkey/relay_url/petname validation
  • SdkError::InvalidInput — new error variant for input validation failures

MCP (opt-in social toolset)

  • publish_note — publish kind:1 text note via WebSocket
  • set_contact_list — replace kind:3 contact list via WebSocket
  • get_event — fetch any global event by ID (kind:0/1/3/30023)
  • get_user_notes — list user's kind:1 notes with composite cursor pagination
  • get_contact_list — fetch user's latest kind:3 contact list

CLI

  • publish-note, set-contact-list, get-event, get-user-notes, get-contact-list

DB

  • Composite keyset cursor (before_id) for stable same-second pagination
  • get_latest_global_replaceable query with NIP-16 canonical ordering
  • Deterministic ORDER BY created_at DESC, id ASC tiebreaker (global change to query_events)
  • global_only filter on EventQuery for defensive channel isolation

Tests

  • 11 unit tests for SDK builders (happy paths, edge cases, rejection)
  • 11 scope-enforcement unit tests in events.rs (allowlist logic, closed-default, empty scopes)
  • 5 LIKE escaping unit tests
  • 6 E2E integration tests (paginated notes, contact list replacement, event fetch)

Architecture

MCP Agent                                    CLI
    │                                          │
    ├─ publish_note ──► WS send_event (kind:1) │ ──► POST /api/events (kind:1)
    ├─ set_contact_list ► WS send_event (kind:3)│ ──► POST /api/events (kind:3)
    │                                          │
    ├─ get_event ─────► GET /api/events/{id}   │ ──► GET /api/events/{id}
    ├─ get_user_notes ► GET /api/users/{pk}/notes  ──► same
    └─ get_contact_list ► GET /api/users/{pk}/contact-list ──► same

Safety invariants

  • Scope-aware allowlist: GET /api/events/{id} uses conditional scope checks — UsersRead for kind:0/3, MessagesRead for kind:1/30023, 404 for everything else (fail-closed)
  • No enumeration signal: All unauthorized paths return 404 with identical message
  • Input validation: hex64 validation at MCP, CLI, REST, and SDK layers; parameterized SQL throughout
  • Global-only ingest: kind:1/3 are always stored with channel_id = NULL by the ingest invariant

Breaking changes

  • query_events now uses ORDER BY created_at DESC, id ASC (was created_at DESC only). This adds a deterministic tiebreaker for same-second events. Existing callers (canvas, WebSocket REQ) now get stable ordering instead of arbitrary row selection.

Deferred

  • Full NIP-10 threading for replies (root + reply + p-tag resolution)
  • follow/unfollow convenience commands
  • Global note feed endpoint (GET /api/notes)
  • Desktop UI for social notes
  • Global reactions on social notes

Files changed (15)

File Change
sprout-sdk/src/lib.rs SdkError::InvalidInput
sprout-sdk/src/builders.rs build_note + build_contact_list + 11 tests
sprout-relay/src/api/events.rs Allowlist fix + 11 scope tests
sprout-relay/src/api/users.rs 2 REST handlers + search hardening
sprout-relay/src/api/mod.rs Re-exports
sprout-relay/src/router.rs 2 routes
sprout-db/src/event.rs Composite cursor + get_latest_global_replaceable + global_only
sprout-db/src/lib.rs Db wrapper
sprout-db/src/user.rs LIKE escaping + 5 tests
sprout-mcp/src/server.rs 5 tools + param structs
sprout-mcp/src/toolsets.rs Social toolset (48 tools, 8 toolsets)
sprout-cli/src/main.rs 5 commands (53 total)
sprout-cli/src/commands/social.rs 5 cmd_* functions
sprout-cli/src/commands/mod.rs pub mod social
sprout-test-client/tests/e2e_rest_api.rs 6 E2E tests

Add 5 MCP tools and 5 CLI commands to expose kind:1 (text note) and
kind:3 (contact list) global Nostr events. Includes relay bug fix for
GET /api/events/{id} allowlist, two new REST endpoints, SDK builders,
and E2E integration tests.

Implements the social toolset for agent tweeting via ACP harness.
Add 4 tests covering the happy paths that were missing:
- UsersRead grants access to kind:0 (profile)
- UsersRead grants access to kind:3 (contact list)
- MessagesRead grants access to kind:30023 (long-form)
- Exhaustive routing test: each kind maps to exactly one scope,
  unknown kinds denied by both scopes
- Add 10 scope-enforcement unit tests in events.rs covering the
  kind-based allowlist (user data vs message kinds, closed-default,
  empty scopes, both scopes)
- Escape LIKE metacharacters in search_users to prevent wildcard
  injection DoS (pre-existing issue)
- Add 200-char length cap on search query parameter
- Add limit floor (clamp 1..100) in get_user_notes
- Fix toolsets.rs comment style to match section convention
- Add #[serde(default)] on CLI ContactEntry fields
Verify that %, _, and \ are properly escaped to prevent wildcard
injection. Tests run without Postgres — pure string logic.
…e refactor

- Document search_users limit cap (max 50) in SearchUsersQuery
- Enforce limit cap at REST layer (.min(50))
- Document get_user_notes response shape (tags/sig omitted)
- Update MCP get_user_notes description to note stripped response
- Standardize hex::decode → nostr_hex::decode in users.rs
- Add global_only field to EventQuery for defensive channel filtering
- Refactor escape_like into named function, tests use production fn
- Expand ORDER BY comment documenting global behavioral change
…onstants, tool docs

- Add MAX_CONTACTS (10,000) cap in build_contact_list for DoS defense
- Deduplicate contact list entries by pubkey (first occurrence wins)
- Extract scope allowlist constants to module level (eliminates shadow tests)
- Improve get_event MCP tool description with explicit scope requirements
- Add publish_note content size pre-validation at MCP layer
- Add social toolset documentation comment
- Add tests: empty note content, duplicate contacts, contact list cap
Without this, the MCP server always starts with only the default
toolset, even when the operator sets SPROUT_TOOLSETS=default,social
on the ACP harness. The env var is now forwarded in build_mcp_servers()
so the MCP server enables the same toolsets.
@tlongwell-block tlongwell-block merged commit 8bfce09 into main Apr 8, 2026
9 checks passed
@tlongwell-block tlongwell-block deleted the pip/social-notes-mcp-cli branch April 8, 2026 18:08
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