Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,67 @@
# Changelog

## [Unreleased]

### Added

- `ClientPubkey`: the rmcp server worker now injects the caller's Nostr public
key (hex) into every **real** inbound request's `extensions` typemap, so
tool/resource/prompt handlers can identify their caller via
`ctx.extensions.get::<ClientPubkey>()`. It is not injected for the transport's
own synthetic announcement/initialization drives (which carry the
`ANNOUNCEMENT_REQUEST_ID` sentinel as their pubkey), so handlers never observe
a bogus caller; oversized (CEP-22) requests carry a real pubkey and are
injected as usual.
This closes the parity gap with the TS adapter's `extra._meta.clientPubkey`, but
uses rmcp's typed extensions (local-only, never on the wire) instead of the
`_meta` field, so it is always on rather than opt-in. The inbound event id is
already reachable as the rmcp request id (`ctx.id`).
- `InboundEvent`: the rmcp server worker now also injects the **full**
client-signed Nostr request event into `extensions`, reachable via
`ctx.extensions.get::<InboundEvent>()`. For gift-wrapped requests this is the
inner, signature-verified event (its `pubkey` matches `ClientPubkey` by
construction); for plaintext requests it is the outer event; for CEP-22
oversized requests it is the carrying `end` frame's event. This exposes
`id`, `pubkey`, `sig`, `tags`, … — notably `sig`, which the server cannot
reconstruct without the client's private key. Handlers that must bind a tool
call to / store / audit the publishing event (e.g. an MLS key-package
coordinator returning the publication event) no longer have to fabricate a
synthetic event. Injected only for real client requests; synthetic
transport-internal requests carry none (`get` returns `None`).
- `IncomingRequest` gained an `event: Option<nostr_sdk::Event>` field carrying
the same event through the channel seam. The FFI mirrors (`FfiIncomingRequest`,
the UniFFI `IncomingRequest`) intentionally do not surface it yet (no FFI
consumer needs the raw event; mirroring `nostr_sdk::Event` + `Tags` is
non-trivial) — the omission is documented in place.

### Fixed

- fix(open-stream): abort server writers on silent client disconnect (CEP-41).
Server→client `OpenStreamWriter`s leaked when a client silently disappeared
(crash/sleep/network drop) without sending `abort`. CEP-41 mandates each peer
maintain an idle timeout and probe the other with `ping`/`pong`, but only the
reader session ran keepalive timers; a pure producer stream (e.g. a
subscription-style tool streaming to a client) was never probed, so a dead
client left the writer — and any upstream producer keyed on `is_active()` —
alive indefinitely. The writer now arms an idle window once it starts
streaming, the server keepalive sweep probes it (mirroring the existing reader
sweep, driven by `OpenStreamWriter::tick`), an inbound `pong` for the stream is
routed to the writer to clear the probe (`ack_probe`), and a missing `pong`
aborts with `"Probe timeout"` (flushing any deferred final response via the
existing `on_abort` hook) **and evicts the dead client's session** (CEP-41
"release local state", mirroring the TS `handleProbeTimeout`, firing the
`SessionStore` eviction callback). Per CEP-41 only inbound frames reset the idle
window — a successful `write()` against the relay is not liveness. Reuses the
reader `idle_timeout_ms` / `probe_timeout_ms` knobs (one idle/probe pair per
stream). This is the rs-sdk port of the TS SDK 0.13.8 fix.
- fix(server): verify plaintext event signatures before trusting `event.pubkey`
for handler identity, the auth allowlist, and request correlation, mirroring
the gift-wrap arm. The default `RelayPool` verifies inbound signatures itself,
but `RelayPoolTrait` is public, so a custom pool that skips verification
(e.g. `MockRelayPool`) left caller identity dependent on an undocumented pool
assumption — and a forged pubkey could bypass the auth allowlist. This is the
rs-sdk half of the TS identity-forgery fix (ContextVM/sdk#64, #69).

## [0.2.0] - 2026-06-24

### Added
Expand Down
4 changes: 4 additions & 0 deletions contextvm-ffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub struct FfiJsonRpcMessage {
}

/// An FFI-safe incoming request (server-side).
///
/// NOTE: the SDK's `IncomingRequest` also carries an `event:
/// Option<nostr_sdk::Event>` (the full client-signed event), intentionally not
/// mirrored here — see `uniffi_types::IncomingRequest` for the rationale.
#[repr(C)]
#[derive(Debug)]
pub struct FfiIncomingRequest {
Expand Down
9 changes: 9 additions & 0 deletions contextvm-ffi/src/uniffi_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ pub struct JsonRpcMessage {
}

/// An incoming MCP request (server-side).
///
/// NOTE: the SDK's `contextvm_sdk::IncomingRequest` also carries an
/// `event: Option<nostr_sdk::Event>` field (the full client-signed event). It
/// is intentionally NOT mirrored here: no FFI consumer needs the raw Nostr
/// event today, and `nostr_sdk::Event` (with its `Tags` collection) is
/// non-trivial to expose across the C ABI + UniFFI. Add an `Event` mirror and a
/// field here when a foreign consumer needs `sig` / event binding.
#[derive(Debug, Clone, uniffi::Record)]
pub struct IncomingRequest {
pub message: JsonRpcMessage,
Expand Down Expand Up @@ -231,6 +238,8 @@ fn incoming_to_uniffi(req: &contextvm_sdk::IncomingRequest) -> IncomingRequest {
client_pubkey: req.client_pubkey.clone(),
event_id: req.event_id.clone(),
is_encrypted: req.is_encrypted,
// req.event (the full client-signed Nostr event) is deliberately not
// forwarded — see the note on `IncomingRequest` above.
}
}

Expand Down
71 changes: 71 additions & 0 deletions docs/server-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,77 @@ after the stream closes. Open-stream is disabled by default; enable it with
`with_open_stream(OpenStreamConfig::enabled())`. See
[open-stream.md](open-stream.md) for a full example.

## Identifying the calling client

Every inbound request carries its caller's Nostr public key. The rmcp worker
injects it into the request `extensions`, so a tool, resource, or prompt handler
reads it from `ctx.extensions`:

```rust
use contextvm_sdk::transport::server::ClientPubkey;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content, ErrorData};
use rmcp::service::RequestContext;
use rmcp::{tool, RoleServer};

#[tool(description = "Echo the caller's pubkey")]
async fn whoami(
&self,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
let pk = ctx.extensions.get::<ClientPubkey>().map(|c| c.0.clone());
Ok(CallToolResult::success(vec![Content::text(pk.unwrap_or_default())]))
}
```

`ClientPubkey` lives in `contextvm_sdk::transport::server` (also re-exported at
the crate root). It is present on every real inbound request, so retrieving it
never returns `None` for a genuine client call. This mirrors the TS adapter's
`extra._meta.clientPubkey`, but via rmcp's typed extensions instead of the
on-wire `_meta` field. The inbound Nostr event id is available separately as
`ctx.id` (the worker rewrites the request id to the event id).

## The inbound Nostr event

When a handler needs the **full** client-signed request event — not just the
pubkey — the worker also injects an `InboundEvent` into `extensions`. This
exposes the event's `id`, `pubkey`, `sig`, `tags`, …, which matters when a
handler must bind a tool call to the publishing event, store it for later
return, or audit it. `sig` in particular is the client's Schnorr signature and
cannot be reconstructed by the server (it does not hold the client's private
key), so it has to be threaded through from ingest.

```rust
use contextvm_sdk::transport::server::InboundEvent;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content, ErrorData};
use rmcp::service::RequestContext;
use rmcp::{tool, RoleServer};

#[tool(description = "Return the inbound event's id + sig")]
async fn audit(
&self,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
match ctx.extensions.get::<InboundEvent>() {
Some(ev) => Ok(CallToolResult::success(vec![Content::text(format!(
"id={} sig={}",
ev.0.id.to_hex(),
ev.0.sig // Display renders hex
))])),
None => Err(ErrorData::invalid_params("no inbound event", None)),
}
}
```

For gift-wrapped requests this is the **inner**, signature-verified event (the
same one whose `pubkey` is surfaced as `ClientPubkey`, so `ev.0.pubkey` and
`ClientPubkey` agree by construction); for plaintext requests it is the outer
request event. It is injected only for real client requests — synthetic
transport-internal requests (announcement / initialization drives, CEP-22
oversized reassembly) carry no event, so `get::<InboundEvent>()` returns `None`
for them. Like `ClientPubkey`, this is local-only and never touches the wire.

## When to use this instead of the gateway

Use this page's approach when you are writing a new Rust MCP server.
Expand Down
2 changes: 1 addition & 1 deletion sdk
Submodule sdk updated from 572926 to 2aaa38
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ pub use transport::discovery_tags::{DiscoveredPeerCapabilities, PeerCapabilities

// ── Transport (server) ──────────────────────────────────────────────
pub use transport::server::{
IncomingRequest, NostrServerTransport, NostrServerTransportConfig, RouteEntry,
ServerEventRouteStore, SessionSnapshot, SessionStore,
ClientPubkey, InboundEvent, IncomingRequest, NostrServerTransport, NostrServerTransportConfig,
RouteEntry, ServerEventRouteStore, SessionSnapshot, SessionStore,
};

// ── rmcp re-export ──────────────────────────────────────────────────
Expand Down
41 changes: 34 additions & 7 deletions src/rmcp_transport/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use crate::core::constants::ANNOUNCEMENT_REQUEST_ID;
use crate::core::error::Result;
use crate::core::types::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest};
use crate::transport::client::{NostrClientTransport, NostrClientTransportConfig};
use crate::transport::server::{NostrServerTransport, NostrServerTransportConfig};
use crate::transport::server::{
ClientPubkey, InboundEvent, NostrServerTransport, NostrServerTransportConfig,
};
use rmcp::model::GetExtensions;
use rmcp::transport::worker::{Worker, WorkerContext, WorkerQuitReason};
use std::collections::HashSet;
Expand Down Expand Up @@ -148,6 +150,7 @@ impl Worker for NostrServerWorker {
mut message,
event_id,
client_pubkey,
event,
..
} = incoming;

Expand Down Expand Up @@ -206,13 +209,37 @@ impl Worker for NostrServerWorker {
}

if let Some(mut rmcp_msg) = internal_to_rmcp_server_rx(&message) {
// CEP-41: inject the open-stream writer into the
// request's `extensions` typemap so the tool handler can
// reach it via `ctx.extensions.get::<OpenStreamWriter>()`.
// The rmcp service loop swaps these extensions straight into
// the handler's `RequestContext` before dispatch. No-op when
// open-stream is disabled or the request has no writer.
// Inject caller identity + (CEP-41) the open-stream writer
// into the request's `extensions` typemap so the handler can
// reach them via `ctx.extensions.get::<T>()`. The rmcp service
// loop swaps these extensions straight into the handler's
// `RequestContext` before dispatch.
if let rmcp::model::JsonRpcMessage::Request(ref mut jr) = rmcp_msg {
// Caller pubkey is relevant to every real request (auth,
// payments, logging) and extensions are local-only. Skip
// the transport's own announcement/init drives, whose
// "pubkey" is the `ANNOUNCEMENT_REQUEST_ID` sentinel —
// injecting it would hand handlers a bogus caller. This
// mirrors the TS adapter's `extra._meta.clientPubkey`,
// but via rmcp's typed extensions rather than the on-wire
// `_meta` field (which is why the TS knob is opt-in but
// this is not). Gate on the sentinel, not on `event`:
// oversized requests carry a real pubkey with no event.
if client_pubkey != ANNOUNCEMENT_REQUEST_ID {
jr.request
.extensions_mut()
.insert(ClientPubkey(client_pubkey.clone()));
}
// Full inbound event (id + sig + pubkey + …) for
// handlers that must bind to / store / audit the
// publishing event. Only real client requests carry
// one; synthetic transport requests have none, so
// handlers see `None` via get() for those.
if let Some(ev) = event {
jr.request.extensions_mut().insert(InboundEvent(ev));
}
// CEP-41: no-op when open-stream is disabled or the
// request has no writer.
if let Some(writer) =
self.transport.get_open_stream_writer(&event_id)
{
Expand Down
Loading
Loading